@neetru/cli 2.28.10 → 2.31.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/CHANGELOG.md +92 -1
- package/README.md +61 -17
- package/dist/commands/code.js +10 -1
- package/dist/commands/code.js.map +1 -1
- package/dist/commands/db-move.d.ts +50 -1
- package/dist/commands/db-move.js +137 -13
- package/dist/commands/db-move.js.map +1 -1
- package/dist/commands/db.js +17 -9
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/deploy.d.ts +45 -0
- package/dist/commands/deploy.js +181 -2
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/deployments.d.ts +7 -1
- package/dist/commands/deployments.js +59 -8
- package/dist/commands/deployments.js.map +1 -1
- package/dist/commands/dev-up.d.ts +7 -5
- package/dist/commands/dev-up.js +24 -23
- package/dist/commands/dev-up.js.map +1 -1
- package/dist/commands/docs-produto.d.ts +65 -0
- package/dist/commands/docs-produto.js +322 -0
- package/dist/commands/docs-produto.js.map +1 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +7 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/new.d.ts +2 -0
- package/dist/commands/new.js +2 -0
- package/dist/commands/new.js.map +1 -1
- package/dist/commands/servers.d.ts +28 -0
- package/dist/commands/servers.js +126 -1
- package/dist/commands/servers.js.map +1 -1
- package/dist/index.js +110 -11
- package/dist/index.js.map +1 -1
- package/dist/lib/api-client.d.ts +8 -1
- package/dist/lib/api-client.js +20 -2
- package/dist/lib/api-client.js.map +1 -1
- package/dist/lib/compose-generator.d.ts +13 -9
- package/dist/lib/compose-generator.js +28 -60
- package/dist/lib/compose-generator.js.map +1 -1
- package/dist/lib/config-schema.d.ts +40 -6
- package/dist/lib/config-schema.js +11 -0
- package/dist/lib/config-schema.js.map +1 -1
- package/dist/lib/product-docs/check.d.ts +62 -0
- package/dist/lib/product-docs/check.js +489 -0
- package/dist/lib/product-docs/check.js.map +1 -0
- package/dist/lib/product-docs/config.d.ts +66 -0
- package/dist/lib/product-docs/config.js +234 -0
- package/dist/lib/product-docs/config.js.map +1 -0
- package/dist/lib/product-docs/dates.d.ts +17 -0
- package/dist/lib/product-docs/dates.js +41 -0
- package/dist/lib/product-docs/dates.js.map +1 -0
- package/dist/lib/product-docs/frontmatter.d.ts +43 -0
- package/dist/lib/product-docs/frontmatter.js +302 -0
- package/dist/lib/product-docs/frontmatter.js.map +1 -0
- package/dist/lib/product-docs/git.d.ts +16 -0
- package/dist/lib/product-docs/git.js +92 -0
- package/dist/lib/product-docs/git.js.map +1 -0
- package/dist/lib/product-docs/markdown.d.ts +27 -0
- package/dist/lib/product-docs/markdown.js +98 -0
- package/dist/lib/product-docs/markdown.js.map +1 -0
- package/dist/lib/product-docs/ops.d.ts +133 -0
- package/dist/lib/product-docs/ops.js +975 -0
- package/dist/lib/product-docs/ops.js.map +1 -0
- package/dist/lib/product-docs/structure.d.ts +61 -0
- package/dist/lib/product-docs/structure.js +239 -0
- package/dist/lib/product-docs/structure.js.map +1 -0
- package/dist/lib/product-docs/sync.d.ts +101 -0
- package/dist/lib/product-docs/sync.js +313 -0
- package/dist/lib/product-docs/sync.js.map +1 -0
- package/dist/lib/product-docs/taxonomy.d.ts +46 -0
- package/dist/lib/product-docs/taxonomy.js +163 -0
- package/dist/lib/product-docs/taxonomy.js.map +1 -0
- package/dist/lib/product-docs/templates.d.ts +53 -0
- package/dist/lib/product-docs/templates.js +245 -0
- package/dist/lib/product-docs/templates.js.map +1 -0
- package/dist/lib/public-secret-scan.d.ts +76 -0
- package/dist/lib/public-secret-scan.js +446 -0
- package/dist/lib/public-secret-scan.js.map +1 -0
- package/package.json +2 -1
- package/templates/dev/Dockerfile.agent +0 -39
- package/templates/dev/agent-entrypoint.sh +0 -41
- package/templates/dev/docker-compose.dev.yml +0 -63
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operações em disco dos docs de produto: `docs init`, `docs init --migrar` e
|
|
3
|
+
* `docs new`. Sem saída de terminal — devolvem o resultado e o comando decide
|
|
4
|
+
* como mostrar (texto ou `--json`).
|
|
5
|
+
*
|
|
6
|
+
* Regras de segurança (decisão do dono 2026-10-03):
|
|
7
|
+
* - `init` é IDEMPOTENTE: nunca sobrescreve, só cria o que falta.
|
|
8
|
+
* - `--migrar` NÃO move nem renomeia nada e NÃO mexe no corpo dos docs: só
|
|
9
|
+
* acrescenta frontmatter inferido nos `.md` que não têm, gera
|
|
10
|
+
* `_nomes-legados.json` (se não existir) e grava um relatório.
|
|
11
|
+
* - Nada é escrito fora da pasta do repositório.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from 'node:fs/promises';
|
|
14
|
+
import * as fsSync from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import { parseDoc, serializeFrontmatter } from './frontmatter.js';
|
|
17
|
+
import { firstH1, toFileWords } from './markdown.js';
|
|
18
|
+
import { CATEGORY_FOLDER, DAY_ONE_MODELS, FILE_NAME_REGEX, FOLDER_RULES, STANDARD_FOLDERS, STOPWORDS, THREAT_MODEL_SECTIONS, TYPE_DEFAULT_FOLDER, TYPE_MODELS, TYPE_PREFIX, topFolder, } from './structure.js';
|
|
19
|
+
import { ADR_EXTRA, FOLDER_LABEL, NORMA_POLITICA_EXTRA, PLANO_RFC_EXTRA, PLANO_SPEC_EXTRA, THREAT_EXTRA, renderChangelog, renderDoc, renderIndex, renderReadme, } from './templates.js';
|
|
20
|
+
import { EMBEDDED_TAXONOMY, productCategories } from './taxonomy.js';
|
|
21
|
+
import { ensureDocsKey, productExposesApi, readProductVersion } from './config.js';
|
|
22
|
+
import { firstCommitDate, git, lastCommitDate } from './git.js';
|
|
23
|
+
import { listDocFiles, readLegacyNames } from './check.js';
|
|
24
|
+
import { localIsoDate, todayIso } from './dates.js';
|
|
25
|
+
const BOM = '\uFEFF';
|
|
26
|
+
async function writeIfMissing(abs, content, dryRun) {
|
|
27
|
+
if (fsSync.existsSync(abs))
|
|
28
|
+
return false;
|
|
29
|
+
if (!dryRun) {
|
|
30
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
31
|
+
await fs.writeFile(abs, content, { encoding: 'utf8', flag: 'wx' });
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
/** Versões que ainda não foram lançadas de fato (scaffold recém-criado). */
|
|
36
|
+
const INITIAL_VERSIONS = new Set(['0.0.0', '0.0.1', '0.1.0']);
|
|
37
|
+
/**
|
|
38
|
+
* Semente do CHANGELOG. Produto "maduro" = versão do package.json além da
|
|
39
|
+
* inicial (0.0.x/0.1.0) OU tags de versão no git. Nesse caso NÃO se escreve
|
|
40
|
+
* "primeira versão" (bug da migração real: PDV 1.0.12 ganhava "Primeira versão
|
|
41
|
+
* do produto"): a entrada registra só o início do changelog neste formato,
|
|
42
|
+
* datada pela tag da versão (ou pelo último commit do package.json).
|
|
43
|
+
*/
|
|
44
|
+
export function changelogSeed(repo, today) {
|
|
45
|
+
const version = readProductVersion(repo);
|
|
46
|
+
const v = version && /^\d+\.\d+\.\d+/.test(version) ? version : '0.1.0';
|
|
47
|
+
const inGit = !!repo.gitRoot;
|
|
48
|
+
const tags = inGit ? (git(repo.configDir, ['tag', '--list']) ?? '').split(/\r?\n/).filter((t) => /^v?\d+\.\d+\.\d+/.test(t)) : [];
|
|
49
|
+
const maduro = !INITIAL_VERSIONS.has(v) || tags.length > 0;
|
|
50
|
+
if (!maduro)
|
|
51
|
+
return { version: v, date: today, maduro, origemData: 'hoje' };
|
|
52
|
+
const tag = tags.find((t) => t === `v${v}` || t === v);
|
|
53
|
+
const tagDate = tag ? git(repo.configDir, ['log', '-1', '--format=%cs', tag]) : null;
|
|
54
|
+
if (tagDate && /^\d{4}-\d{2}-\d{2}$/.test(tagDate))
|
|
55
|
+
return { version: v, date: tagDate, maduro, origemData: 'tag' };
|
|
56
|
+
const pkgDate = inGit ? git(repo.configDir, ['log', '-1', '--format=%cs', '--', 'package.json']) : null;
|
|
57
|
+
if (pkgDate && /^\d{4}-\d{2}-\d{2}$/.test(pkgDate))
|
|
58
|
+
return { version: v, date: pkgDate, maduro, origemData: 'package.json no git' };
|
|
59
|
+
return { version: v, date: today, maduro, origemData: 'hoje' };
|
|
60
|
+
}
|
|
61
|
+
function dayOneSpecs(repo, today) {
|
|
62
|
+
const name = repo.name;
|
|
63
|
+
const base = { product: repo.slug, status: 'draft', date: today, updated: today, owner: 'produto' };
|
|
64
|
+
return {
|
|
65
|
+
'produto/VISAO_DO_PRODUTO.md': {
|
|
66
|
+
...base,
|
|
67
|
+
title: 'Visão do produto',
|
|
68
|
+
description: `O problema que o ${name} resolve, para quem e o que ele faz, como referência para quem chega ao produto.`,
|
|
69
|
+
category: 'Produto',
|
|
70
|
+
subcategory: 'visao',
|
|
71
|
+
type: 'referencia',
|
|
72
|
+
audience: 'dev-produto',
|
|
73
|
+
lead: 'Para a equipe do produto, o staff do Core e quem escreve a ajuda ao usuário. Rascunho: complete antes do primeiro deploy em produção.',
|
|
74
|
+
sections: DAY_ONE_MODELS['produto/VISAO_DO_PRODUTO.md'],
|
|
75
|
+
},
|
|
76
|
+
'arquitetura/ARQUITETURA.md': {
|
|
77
|
+
...base,
|
|
78
|
+
title: `Arquitetura do ${name}`,
|
|
79
|
+
description: `Como o ${name} é montado e como se integra ao Core: SDK, login, banco, credenciais e ambientes.`,
|
|
80
|
+
category: 'Plataforma',
|
|
81
|
+
subcategory: 'visao-geral',
|
|
82
|
+
type: 'arquitetura',
|
|
83
|
+
audience: 'dev-produto',
|
|
84
|
+
lead: 'Para quem desenvolve o produto. Rascunho: complete antes do primeiro deploy em produção.',
|
|
85
|
+
sections: DAY_ONE_MODELS['arquitetura/ARQUITETURA.md'],
|
|
86
|
+
extra: {
|
|
87
|
+
'Integração com o Core': [
|
|
88
|
+
'- **Dados:** só pelo `@neetru/sdk` — o produto nunca fala direto com o banco do Core.',
|
|
89
|
+
'- **Login:** OIDC do Core.',
|
|
90
|
+
'- **Banco:** banco isolado por produto, provisionado pelo Core (`neetru db`).',
|
|
91
|
+
'- **Credenciais:** injetadas no servidor pelo Core; nunca no navegador nem em `.env` versionado.',
|
|
92
|
+
'- **Ambientes:** dev → homologação (staging) → produção.',
|
|
93
|
+
].join('\n'),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
'runbooks/RUNBOOK_DEPLOY.md': {
|
|
97
|
+
...base,
|
|
98
|
+
title: `Deploy do ${name}`,
|
|
99
|
+
description: `Passo a passo para levar uma versão do ${name} de dev a produção, com verificação e como desfazer.`,
|
|
100
|
+
category: 'Operações',
|
|
101
|
+
subcategory: 'producao',
|
|
102
|
+
type: 'runbook',
|
|
103
|
+
audience: 'dev-produto',
|
|
104
|
+
lead: 'Para quem publica uma versão. Rascunho: confira os comandos com o produto antes do primeiro deploy em produção.',
|
|
105
|
+
sections: DAY_ONE_MODELS['runbooks/RUNBOOK_DEPLOY.md'],
|
|
106
|
+
extra: {
|
|
107
|
+
'Passo a passo': [
|
|
108
|
+
'1. Atualize o `CHANGELOG.md` com a versão do `package.json`.',
|
|
109
|
+
'2. Se o esquema do banco mudou: `neetru db apply` (veja a skill `neetru-migrations`).',
|
|
110
|
+
'3. `neetru deploy --env staging` e confira.',
|
|
111
|
+
'4. `neetru deploy --env prod` (pede MFA).',
|
|
112
|
+
].join('\n'),
|
|
113
|
+
'Verificação': '- `neetru deployments get <deploymentId>` mostra as fases e o erro, se houver.\n- O endereço de saúde do produto responde 200.',
|
|
114
|
+
'Como desfazer': '- `neetru deployments rollback <deploymentId>`.',
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
'api/API_V1.md': {
|
|
118
|
+
...base,
|
|
119
|
+
title: 'Contrato da API v1',
|
|
120
|
+
description: 'Endpoints, autenticação, erros e regras de evolução da /api/v1 que o app e as integrações usam.',
|
|
121
|
+
category: 'API',
|
|
122
|
+
subcategory: 'rest-v1',
|
|
123
|
+
type: 'referencia',
|
|
124
|
+
audience: 'dev-produto',
|
|
125
|
+
lead: 'Para quem consome a API do produto (app móvel, integrações). O doc descreve o que existe; se fugir da convenção, diga onde e por quê.',
|
|
126
|
+
sections: DAY_ONE_MODELS['api/API_V1.md'],
|
|
127
|
+
extra: { Endpoints: '### GET /api/v1/me\n\n<!-- Autenticação, corpo, respostas, erros e exemplo. -->' },
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function nameOutOfRule(rel) {
|
|
132
|
+
const base = path.posix.basename(rel);
|
|
133
|
+
const top = topFolder(rel);
|
|
134
|
+
if (top === 'superpowers' || top === '_archive' || top === '_audit' || base === 'INDEX.md')
|
|
135
|
+
return false;
|
|
136
|
+
return !FILE_NAME_REGEX.test(base);
|
|
137
|
+
}
|
|
138
|
+
/** Agrupa para o índice: pastas do padrão → superpowers → fora do padrão → raiz → arquivo. */
|
|
139
|
+
export function groupIndex(items) {
|
|
140
|
+
const by = new Map();
|
|
141
|
+
for (const it of items) {
|
|
142
|
+
const top = topFolder(it.rel);
|
|
143
|
+
if (!by.has(top))
|
|
144
|
+
by.set(top, []);
|
|
145
|
+
by.get(top).push(it);
|
|
146
|
+
}
|
|
147
|
+
const groups = [];
|
|
148
|
+
const take = (folder, label, foraDoPadrao, nota) => {
|
|
149
|
+
const list = by.get(folder);
|
|
150
|
+
if (list?.length)
|
|
151
|
+
groups.push({ label, files: list, foraDoPadrao, nota });
|
|
152
|
+
by.delete(folder);
|
|
153
|
+
};
|
|
154
|
+
for (const [folder, label] of Object.entries(FOLDER_LABEL))
|
|
155
|
+
take(folder, label, false);
|
|
156
|
+
take('superpowers', 'Planos (superpowers)', false);
|
|
157
|
+
const others = [...by.keys()].filter((k) => k !== '' && k !== '_archive' && k !== '_audit').sort();
|
|
158
|
+
for (const k of others) {
|
|
159
|
+
take(k, `${k}/ — fora do padrão`, true, 'Pasta fora da estrutura do protocolo: mova os docs para a pasta do assunto (veja o relatório do `neetru docs init --migrar`).');
|
|
160
|
+
}
|
|
161
|
+
take('', 'Raiz de docs/ — fora do padrão', true, 'Na raiz de docs/ só fica este índice: mova estes docs para a pasta do assunto.');
|
|
162
|
+
take('_archive', 'Arquivo morto', false);
|
|
163
|
+
take('_audit', 'Relatórios automáticos', false);
|
|
164
|
+
return groups;
|
|
165
|
+
}
|
|
166
|
+
/** `neetru docs init` — cria o mínimo do primeiro dia sem sobrescrever nada. */
|
|
167
|
+
export async function initProductDocs(repo, opts = {}) {
|
|
168
|
+
const today = opts.today ?? todayIso();
|
|
169
|
+
const dryRun = !!opts.dryRun;
|
|
170
|
+
// --com-api é pedido explícito: liga `docs.api` mesmo que a chave já exista com false.
|
|
171
|
+
const configKeysAdded = await ensureDocsKey(repo, opts.comApi ? { api: true } : {}, dryRun, opts.comApi ? ['api'] : []);
|
|
172
|
+
const withApi = !!opts.comApi || productExposesApi(repo);
|
|
173
|
+
const docsRoot = repo.docs.root;
|
|
174
|
+
const absDocs = path.join(repo.cwd, docsRoot);
|
|
175
|
+
const created = [];
|
|
176
|
+
const skipped = [];
|
|
177
|
+
const folders = [];
|
|
178
|
+
for (const f of STANDARD_FOLDERS) {
|
|
179
|
+
const abs = path.join(absDocs, f);
|
|
180
|
+
if (!fsSync.existsSync(abs)) {
|
|
181
|
+
folders.push(`${docsRoot}/${f}`);
|
|
182
|
+
if (!dryRun)
|
|
183
|
+
await fs.mkdir(abs, { recursive: true });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const specs = dayOneSpecs(repo, today);
|
|
187
|
+
for (const [rel, spec] of Object.entries(specs)) {
|
|
188
|
+
if (rel === 'api/API_V1.md' && !withApi)
|
|
189
|
+
continue;
|
|
190
|
+
const repoRel = `${docsRoot}/${rel}`;
|
|
191
|
+
if (await writeIfMissing(path.join(absDocs, rel), renderDoc(spec), dryRun))
|
|
192
|
+
created.push(repoRel);
|
|
193
|
+
else
|
|
194
|
+
skipped.push(repoRel);
|
|
195
|
+
}
|
|
196
|
+
// index.md por último: lista TUDO o que existe (inclusive o que acabou de
|
|
197
|
+
// ser criado), marcando o que está fora do padrão.
|
|
198
|
+
const indexRel = `${docsRoot}/index.md`;
|
|
199
|
+
if (!fsSync.existsSync(path.join(absDocs, 'index.md'))) {
|
|
200
|
+
const existing = await listDocFiles(repo);
|
|
201
|
+
const planned = created.filter((c) => c.startsWith(`${docsRoot}/`)).map((c) => c.slice(docsRoot.length + 1));
|
|
202
|
+
const all = [...new Set([...existing, ...planned])].filter((r) => r !== 'index.md').sort();
|
|
203
|
+
const items = [];
|
|
204
|
+
for (const rel of all) {
|
|
205
|
+
let title = specs[rel]?.title ?? path.posix.basename(rel, '.md');
|
|
206
|
+
try {
|
|
207
|
+
const p = parseDoc(await fs.readFile(path.join(absDocs, rel), 'utf8'));
|
|
208
|
+
title = (typeof p.data.title === 'string' && p.data.title) || firstH1(p.body) || title;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
/* dry-run: ainda não existe */
|
|
212
|
+
}
|
|
213
|
+
items.push({ rel, title, nomeForaDaRegra: nameOutOfRule(rel) });
|
|
214
|
+
}
|
|
215
|
+
if (await writeIfMissing(path.join(absDocs, 'index.md'), renderIndex({ product: repo.slug, name: repo.name, today, groups: groupIndex(items) }), dryRun)) {
|
|
216
|
+
created.push(indexRel);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
skipped.push(indexRel);
|
|
221
|
+
}
|
|
222
|
+
// README / CHANGELOG na raiz (sem frontmatter)
|
|
223
|
+
const readme = renderReadme(repo.name).split('docs/').join(`${docsRoot}/`);
|
|
224
|
+
if (await writeIfMissing(path.join(repo.cwd, 'README.md'), readme, dryRun))
|
|
225
|
+
created.push('README.md');
|
|
226
|
+
else
|
|
227
|
+
skipped.push('README.md');
|
|
228
|
+
if (await writeIfMissing(path.join(repo.cwd, 'CHANGELOG.md'), renderChangelog(changelogSeed(repo, today)), dryRun)) {
|
|
229
|
+
created.push('CHANGELOG.md');
|
|
230
|
+
}
|
|
231
|
+
else
|
|
232
|
+
skipped.push('CHANGELOG.md');
|
|
233
|
+
// .gitkeep nas pastas que ficaram vazias (o git não versiona pasta vazia)
|
|
234
|
+
if (!dryRun) {
|
|
235
|
+
for (const f of STANDARD_FOLDERS) {
|
|
236
|
+
const abs = path.join(absDocs, f);
|
|
237
|
+
try {
|
|
238
|
+
if ((await fs.readdir(abs)).length === 0)
|
|
239
|
+
await fs.writeFile(path.join(abs, '.gitkeep'), '');
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
/* ignora */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return { slug: repo.slug, docsRoot, created, skipped, folders, configKeysAdded, withApi, dryRun };
|
|
247
|
+
}
|
|
248
|
+
/** Pastas legadas comuns nos produtos → pasta do protocolo. */
|
|
249
|
+
const LEGACY_FOLDERS = {
|
|
250
|
+
architecture: 'arquitetura',
|
|
251
|
+
arquitetura: 'arquitetura',
|
|
252
|
+
design: 'arquitetura',
|
|
253
|
+
'guia-do-usuario': 'usuario',
|
|
254
|
+
'guia-usuario': 'usuario',
|
|
255
|
+
help: 'usuario',
|
|
256
|
+
ajuda: 'usuario',
|
|
257
|
+
_review: 'relatorios',
|
|
258
|
+
review: 'relatorios',
|
|
259
|
+
reviews: 'relatorios',
|
|
260
|
+
auditorias: 'relatorios',
|
|
261
|
+
deploy: 'runbooks',
|
|
262
|
+
runbook: 'runbooks',
|
|
263
|
+
adr: 'decisoes',
|
|
264
|
+
adrs: 'decisoes',
|
|
265
|
+
decisions: 'decisoes',
|
|
266
|
+
guides: 'guias',
|
|
267
|
+
security: 'seguranca',
|
|
268
|
+
integrations: 'integracoes',
|
|
269
|
+
plans: 'planos',
|
|
270
|
+
specs: 'planos',
|
|
271
|
+
};
|
|
272
|
+
function guessTypeFromName(base) {
|
|
273
|
+
const n = base.replace(/\.md$/i, '').toUpperCase().replace(/-/g, '_');
|
|
274
|
+
const rules = [
|
|
275
|
+
[/^ADR_?\d+/, 'adr'],
|
|
276
|
+
[/^(RUNBOOK|SETUP)_|_RUNBOOK$|DEPLOY_PROD/, 'runbook'],
|
|
277
|
+
[/^GUIA_|^GUIDE_|_GUIDE$|^HOWTO/, 'guia'],
|
|
278
|
+
[/^DESIGN_SYSTEM/, 'referencia'],
|
|
279
|
+
[/^(ARQUITETURA|ARCHITECTURE|PLATFORM|DESIGN)(_|$)/, 'arquitetura'],
|
|
280
|
+
[/^(PROTOCOLO|POLITICA|POLICY)_/, 'norma'],
|
|
281
|
+
[/^(PLANO|PROJ|PLAN|SPEC|RFC|ROADMAP|EXECUTION_PLAN)(_|$)/, 'plano'],
|
|
282
|
+
[/^(AUDITORIA|AUDIT|REVIEW|ANALISE|RELATORIO|INVENTARIO|ACHADO|BACKLOG|STATUS)(_|$)/, 'relatorio'],
|
|
283
|
+
[/^(INCIDENTE|INCIDENT|POSTMORTEM)(_|$)/, 'incidente'],
|
|
284
|
+
[/^(ATA|DECISOES|MINUTES)(_|$)/, 'ata'],
|
|
285
|
+
[/^(INDEX|README)$/, 'indice'],
|
|
286
|
+
[/^CHANGELOG/, 'changelog'],
|
|
287
|
+
[/(_API_REFERENCE|_REFERENCE|^REFERENCIA_|^API(_V\d+)?$)/, 'referencia'],
|
|
288
|
+
];
|
|
289
|
+
for (const [re, type] of rules)
|
|
290
|
+
if (re.test(n))
|
|
291
|
+
return { type, why: `prefixo do nome (${base})`, conf: 'alta' };
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
function guessCategoryFromName(base, cats) {
|
|
295
|
+
const n = base.toUpperCase().replace(/-/g, '_');
|
|
296
|
+
const rules = [
|
|
297
|
+
[/PRIMEIROS_PASSOS|COMECE_AQUI|GETTING_STARTED|ONBOARDING/, 'Ajuda ao usuário', 'primeiros-passos'],
|
|
298
|
+
[/DESIGN_SYSTEM|UI_|UX_/, 'Desenvolvimento', 'interface'],
|
|
299
|
+
[/FAQ|PERGUNTAS/, 'Ajuda ao usuário', 'perguntas-frequentes'],
|
|
300
|
+
[/MELI|MERCADO_?LIVRE|MAGALU|AMAZON|SHOPEE|MARKETPLACE|BLING|TRAY/, 'Integrações', 'marketplaces'],
|
|
301
|
+
[/STRIPE|PAGAMENTO|PAYMENT|PIX|BOLETO|ASAAS|PAGSEGURO|MERCADO_?PAGO/, 'Integrações', 'pagamentos'],
|
|
302
|
+
[/NFE|NFCE|FISCAL|SEFAZ|NOTA_FISCAL|SAT_/, 'Integrações', 'fiscal'],
|
|
303
|
+
[/WHATSAPP|SMS|EMAIL|E_MAIL|MENSAG|TELEGRAM/, 'Integrações', 'mensageria'],
|
|
304
|
+
[/THREAT|AMEACA/, 'Segurança', 'vulnerabilidades'],
|
|
305
|
+
[/SECURITY|SEGURANCA|LGPD|PRIVAC/, 'Segurança', 'configuracao-segura'],
|
|
306
|
+
[/DATABASE|MODELO_DE_DADOS|DATA_MODEL|SCHEMA|BANCO/, 'Dados', 'bancos-de-produto'],
|
|
307
|
+
[/MIGRA/, 'Dados', 'migracoes'],
|
|
308
|
+
[/BACKUP/, 'Dados', 'backup-e-recuperacao'],
|
|
309
|
+
[/DEPLOY|RELEASE/, 'Operações', 'producao'],
|
|
310
|
+
[/INCIDENT|ALERT/, 'Observabilidade', 'incidentes'],
|
|
311
|
+
[/GLOSSARIO|GLOSSARY/, 'Produto', 'glossario'],
|
|
312
|
+
[/ROADMAP/, 'Produto', 'roadmap'],
|
|
313
|
+
[/VISAO|VISION|EXECUTIVE_SUMMARY|OVERVIEW|PRODUTO/, 'Produto', 'visao'],
|
|
314
|
+
[/OIDC|OAUTH|LOGIN|AUTH|SSO/, 'Identidade', 'oidc'],
|
|
315
|
+
[/PERMISS|RBAC|ROLES/, 'Identidade', 'permissoes'],
|
|
316
|
+
[/API/, 'API', 'rest-v1'],
|
|
317
|
+
[/DEV_LOCAL|DESENVOLVIMENTO_LOCAL|LOCAL_DEV|SETUP/, 'Desenvolvimento', 'ambiente-local'],
|
|
318
|
+
[/TEST|QA|E2E/, 'Desenvolvimento', 'qualidade-e-testes'],
|
|
319
|
+
];
|
|
320
|
+
for (const [re, category, subcategory] of rules) {
|
|
321
|
+
// casa no começo de uma palavra do nome ("PASSOS" não casa com SSO, "LATEST" não casa com TEST)
|
|
322
|
+
const anchored = new RegExp(`(?:^|_)(?:${re.source})`);
|
|
323
|
+
if (anchored.test(n) && cats[category]?.includes(subcategory)) {
|
|
324
|
+
return { category, subcategory, why: `palavra-chave no nome (${base})`, conf: 'media' };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
const FAMILY_STATUS = { vivo: 'active', plano: 'proposed', registro: 'historical' };
|
|
330
|
+
/** Primeiro parágrafo de texto do corpo, reduzido a uma frase de até 200 caracteres. */
|
|
331
|
+
function guessDescription(body) {
|
|
332
|
+
// Junta os parágrafos de texto (fora de código, cabeçalho, lista, tabela).
|
|
333
|
+
const paras = [];
|
|
334
|
+
let cur = [];
|
|
335
|
+
let inFence = false;
|
|
336
|
+
const flush = () => {
|
|
337
|
+
if (cur.length)
|
|
338
|
+
paras.push(cur.join(' '));
|
|
339
|
+
cur = [];
|
|
340
|
+
};
|
|
341
|
+
for (const l of body.split(/\r?\n/)) {
|
|
342
|
+
if (/^\s{0,3}(```|~~~)/.test(l)) {
|
|
343
|
+
flush();
|
|
344
|
+
inFence = !inFence;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (inFence)
|
|
348
|
+
continue;
|
|
349
|
+
const t = l.trim();
|
|
350
|
+
// linha indentada = continuação de item de lista ou código indentado
|
|
351
|
+
if (!t || /^\s{2,}/.test(l) || /^(#|\||[-*+]\s|\d+\.\s|<!--|!\[|---|===)/.test(t)) {
|
|
352
|
+
flush();
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
cur.push(t.replace(/^>\s*/, ''));
|
|
356
|
+
if (paras.length >= 6)
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
flush();
|
|
360
|
+
for (const para of paras.slice(0, 6)) {
|
|
361
|
+
const text = para
|
|
362
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
363
|
+
.replace(/[`*_]/g, '')
|
|
364
|
+
.replace(/\p{Extended_Pictographic}\uFE0F?/gu, '')
|
|
365
|
+
.replace(/\s+/g, ' ')
|
|
366
|
+
.trim();
|
|
367
|
+
// Pula aviso de estado e linha de metadado ("Status: …", "Data: …", "Nota: …").
|
|
368
|
+
if (/^(atualizado|obsoleto|desatualizado|parcialmente|snapshot|aten[çc][ãa]o|aviso|deprecated|legado)\b/i.test(text))
|
|
369
|
+
continue;
|
|
370
|
+
if (/^[\wÀ-ú ]{2,28}:\s/.test(text))
|
|
371
|
+
continue;
|
|
372
|
+
const first = (text.match(/^.*?[.!?](\s|$)/)?.[0] ?? text).trim().replace(/[:;,]+$/, '');
|
|
373
|
+
if (first.length < 20)
|
|
374
|
+
continue;
|
|
375
|
+
const cut = first.length > 200 ? `${first.slice(0, 196).replace(/\s+\S*$/, '')}…` : first;
|
|
376
|
+
return /[.!?…]$/.test(cut) ? cut : `${cut}.`;
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
/** `_revisado: AAAA-MM-DD_` (e variações) escrito no corpo. */
|
|
381
|
+
function bodyRevisedDate(body) {
|
|
382
|
+
const m = body.match(/_?\*?(revisado|atualizado|updated|última atualização|ultima atualizacao)\*?\s*[:=]\s*\*?(\d{4}-\d{2}-\d{2})/i);
|
|
383
|
+
return m ? m[2] : null;
|
|
384
|
+
}
|
|
385
|
+
function humanize(base) {
|
|
386
|
+
const t = base.replace(/\.md$/i, '').replace(/^\d{4}-\d{2}-\d{2}[-_]/, '').replace(/[-_]+/g, ' ').trim().toLowerCase();
|
|
387
|
+
return t.charAt(0).toUpperCase() + t.slice(1);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Nome pela regra do §8.1 — preservando a data ISO COM hífens no fim
|
|
391
|
+
* (`AUDITORIA-GERAL-2026-06-30.md` → `AUDITORIA_GERAL_2026-06-30.md`; antes
|
|
392
|
+
* virava `…_2026_06_30`, que falha no NM002). Registro sem data no nome ganha
|
|
393
|
+
* a do frontmatter; doc vivo perde a data (data no nome é só de registro).
|
|
394
|
+
*/
|
|
395
|
+
export function suggestedName(base, type, family, fmDate) {
|
|
396
|
+
const stem = base.replace(/\.md$/i, '');
|
|
397
|
+
const dm = stem.match(/(\d{4}-\d{2}(?:-\d{2})?)/);
|
|
398
|
+
const nameDate = dm?.[1];
|
|
399
|
+
const words = toFileWords(nameDate ? stem.replace(nameDate, ' ') : stem);
|
|
400
|
+
if (type === 'adr') {
|
|
401
|
+
const num = words[0] === 'ADR' && /^\d+$/.test(words[1] ?? '') ? words[1].padStart(4, '0') : '0001';
|
|
402
|
+
const rest = words.slice(words[0] === 'ADR' ? 2 : 0);
|
|
403
|
+
return `ADR-${num}_${rest.join('_') || 'DECISAO'}.md`;
|
|
404
|
+
}
|
|
405
|
+
// "01-visao" → descarta o número de ordem
|
|
406
|
+
let clean = /^\d+$/.test(words[0] ?? '') ? words.slice(1) : words;
|
|
407
|
+
if (type === 'relatorio' && clean[0] === 'AUDIT')
|
|
408
|
+
clean = ['AUDITORIA', ...clean.slice(1)];
|
|
409
|
+
if (!clean.length)
|
|
410
|
+
clean = ['DOC'];
|
|
411
|
+
if (/^\d/.test(clean[0]))
|
|
412
|
+
clean = ['DOC', ...clean];
|
|
413
|
+
const subject = clean.join('_');
|
|
414
|
+
const date = family === 'registro' ? nameDate ?? fmDate : family === 'plano' ? nameDate : undefined;
|
|
415
|
+
return date ? `${subject}_${date}.md` : `${subject}.md`;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Destinos que o §10 do protocolo de produtos nomeia (os mesmos casos nos 3
|
|
419
|
+
* produtos): CHANGELOG na raiz, RUNBOOK_DEPLOY em runbooks/, API_V1 em api/,
|
|
420
|
+
* ARQUITETURA/MODELO_DE_DADOS em arquitetura/, a visão a partir do resumo
|
|
421
|
+
* executivo, os modelos do zelo que saem do repo.
|
|
422
|
+
*/
|
|
423
|
+
function canonicalDestination(rel, cats) {
|
|
424
|
+
const r = rel.toLowerCase().replace(/\\/g, '/');
|
|
425
|
+
const base = path.posix.basename(r);
|
|
426
|
+
const cg = (category, subcategory, why, conf = 'alta') => cats[category]?.includes(subcategory) ? { category, subcategory, why, conf } : { category: 'Plataforma', subcategory: 'visao-geral', why, conf: 'baixa' };
|
|
427
|
+
const t = (type, why, conf = 'alta') => ({ type, why, conf });
|
|
428
|
+
const why = '§10 do protocolo de produtos';
|
|
429
|
+
if (/^changelog[-_.]/.test(base) && topFolder(rel) === '') {
|
|
430
|
+
return {
|
|
431
|
+
type: t('changelog', `${why} (changelog solto em docs/)`),
|
|
432
|
+
category: cg('Documentação', 'indices', why, 'media'),
|
|
433
|
+
destino: 'CHANGELOG.md (raiz do repositório — junte o conteúdo no formato Keep a Changelog)',
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
if (r === 'deploy/readme.md' || /deploy[-_](prod[-_])?runbook|runbook[-_]deploy|^deploy\.md$/.test(base)) {
|
|
437
|
+
return { type: t('runbook', `${why} (deploy)`), category: cg('Operações', 'producao', why), destinoDocs: 'runbooks/RUNBOOK_DEPLOY.md' };
|
|
438
|
+
}
|
|
439
|
+
if (/^(architecture|arquitetura)\.md$/.test(base) && ['', 'architecture', 'arquitetura', 'design'].includes(topFolder(rel))) {
|
|
440
|
+
return { type: t('arquitetura', why), category: cg('Plataforma', 'visao-geral', why), destinoDocs: 'arquitetura/ARQUITETURA.md' };
|
|
441
|
+
}
|
|
442
|
+
if (/^(database[-_]model|database|data[-_]model|modelo[-_]de[-_]dados)\.md$/.test(base)) {
|
|
443
|
+
return { type: t('referencia', why), category: cg('Dados', 'bancos-de-produto', why), destinoDocs: 'arquitetura/MODELO_DE_DADOS.md' };
|
|
444
|
+
}
|
|
445
|
+
if (/^api[-_]v1\.md$/.test(base)) {
|
|
446
|
+
return { type: t('referencia', why), category: cg('API', 'rest-v1', why), destinoDocs: 'api/API_V1.md' };
|
|
447
|
+
}
|
|
448
|
+
if (/^executive[-_]summary\.md$/.test(base)) {
|
|
449
|
+
return {
|
|
450
|
+
type: t('referencia', why),
|
|
451
|
+
category: cg('Produto', 'visao', why),
|
|
452
|
+
destinoDocs: 'produto/VISAO_DO_PRODUTO.md',
|
|
453
|
+
nota: 'Base para a visão do produto: reescreva no modelo da VISAO_DO_PRODUTO.md (§10).',
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
if (/(^|\/)design\/modelos\//.test(r)) {
|
|
457
|
+
return {
|
|
458
|
+
type: t('referencia', 'modelo de documento do zelo'),
|
|
459
|
+
category: cg('Documentação', 'protocolos', why, 'media'),
|
|
460
|
+
destino: '(remover do repositório — os modelos agora vêm embutidos na CLI: `neetru docs new`)',
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
/** Destino + tipo COERENTES: o tipo sugerido é sempre aceito na pasta sugerida (sem FM005 depois de mover). */
|
|
466
|
+
function planDestination(a) {
|
|
467
|
+
const { base, top, effFolder, cats } = a;
|
|
468
|
+
const named = guessTypeFromName(base);
|
|
469
|
+
const catByName = guessCategoryFromName(base, cats);
|
|
470
|
+
const ruleOf = (folder) => FOLDER_RULES.find((r) => r.folder === folder);
|
|
471
|
+
const accepts = (folder, type) => {
|
|
472
|
+
const r = ruleOf(folder);
|
|
473
|
+
return !!r && (r.types === 'qualquer' || r.types.includes(type));
|
|
474
|
+
};
|
|
475
|
+
const folderCat = (folder, why) => {
|
|
476
|
+
const r = ruleOf(folder);
|
|
477
|
+
return r && cats[r.category]?.includes(r.subcategory) ? { category: r.category, subcategory: r.subcategory, why, conf: 'media' } : null;
|
|
478
|
+
};
|
|
479
|
+
const fallbackCat = { category: 'Plataforma', subcategory: 'visao-geral', why: 'sem pista — padrão', conf: 'baixa' };
|
|
480
|
+
// Arquivo morto e superpowers ficam onde estão.
|
|
481
|
+
if (top === '_archive' || top === 'superpowers') {
|
|
482
|
+
const type = top === 'superpowers' ? { type: 'plano', why: 'pasta superpowers/', conf: 'alta' } : named ?? { type: 'referencia', why: 'arquivo morto', conf: 'baixa' };
|
|
483
|
+
return { type, category: catByName ?? folderCat(top, `padrão da pasta ${top}/`) ?? fallbackCat, folder: top, keepSubPath: true };
|
|
484
|
+
}
|
|
485
|
+
// Índice da raiz.
|
|
486
|
+
if (top === '' && (base.toLowerCase() === 'index.md' || base.toLowerCase() === 'readme.md')) {
|
|
487
|
+
return { type: { type: 'indice', why: 'índice na raiz de docs/', conf: 'alta' }, category: { category: 'Documentação', subcategory: 'indices', why: 'índice', conf: 'alta' } };
|
|
488
|
+
}
|
|
489
|
+
// Pasta conhecida (do protocolo ou legada): a pasta manda, salvo registro/plano de nome claro.
|
|
490
|
+
if (effFolder) {
|
|
491
|
+
const isReadme = base.toLowerCase() === 'readme.md';
|
|
492
|
+
let type = named && !(isReadme && named.type === 'indice') ? named : null;
|
|
493
|
+
const fam = type ? EMBEDDED_TAXONOMY.types[type.type] : undefined;
|
|
494
|
+
if (type && !accepts(effFolder, type.type) && (fam === 'registro' || fam === 'plano' || type.type === 'adr')) {
|
|
495
|
+
const folder = CATEGORY_FOLDER[catByName?.category ?? ''] && accepts(CATEGORY_FOLDER[catByName.category], type.type) ? CATEGORY_FOLDER[catByName.category] : TYPE_DEFAULT_FOLDER[type.type];
|
|
496
|
+
return { type, category: catByName ?? folderCat(folder, `padrão da pasta ${folder}/`) ?? fallbackCat, folder };
|
|
497
|
+
}
|
|
498
|
+
if (!type || !accepts(effFolder, type.type)) {
|
|
499
|
+
const r = ruleOf(effFolder);
|
|
500
|
+
const t0 = r.types === 'qualquer' ? 'referencia' : r.types[0];
|
|
501
|
+
type = { type: t0, why: `${isReadme ? 'README de pasta — ' : ''}tipo da pasta ${effFolder}/ (§3)`, conf: r.types !== 'qualquer' && r.types.length === 1 ? 'alta' : 'media' };
|
|
502
|
+
}
|
|
503
|
+
let category = catByName ?? folderCat(effFolder, `padrão da pasta ${effFolder}/`) ?? fallbackCat;
|
|
504
|
+
if (effFolder === 'usuario' && category.category !== 'Ajuda ao usuário')
|
|
505
|
+
category = folderCat('usuario', 'padrão da pasta usuario/') ?? category;
|
|
506
|
+
return { type, category, folder: effFolder, keepSubPath: true };
|
|
507
|
+
}
|
|
508
|
+
// Raiz de docs/ ou pasta desconhecida: escolhe o destino e um tipo que caiba nele.
|
|
509
|
+
let type = named && named.type !== 'indice' ? named : { type: 'referencia', why: top ? `pasta ${top}/ fora do padrão, sem pista no nome` : 'arquivo solto na raiz de docs/, sem pista no nome', conf: 'baixa' };
|
|
510
|
+
const byCat = catByName ? CATEGORY_FOLDER[catByName.category] : undefined;
|
|
511
|
+
let folder = byCat && accepts(byCat, type.type) ? byCat : TYPE_DEFAULT_FOLDER[type.type] ?? byCat ?? 'arquitetura';
|
|
512
|
+
if (!accepts(folder, type.type)) {
|
|
513
|
+
const r = ruleOf(folder);
|
|
514
|
+
type = { type: r.types === 'qualquer' ? 'referencia' : r.types[0], why: `tipo coerente com o destino ${folder}/`, conf: 'baixa' };
|
|
515
|
+
}
|
|
516
|
+
const category = catByName ?? folderCat(folder, `padrão da pasta de destino ${folder}/`) ?? fallbackCat;
|
|
517
|
+
return { type, category, folder };
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* `neetru docs init --migrar` — frontmatter inferido nos `.md` sem frontmatter,
|
|
521
|
+
* `_nomes-legados.json` e relatório. NÃO move, NÃO renomeia, NÃO edita corpo.
|
|
522
|
+
*/
|
|
523
|
+
export async function migrateProductDocs(repo, opts = {}) {
|
|
524
|
+
const dryRun = !!opts.dryRun;
|
|
525
|
+
const today = opts.today ?? todayIso();
|
|
526
|
+
const cats = productCategories(opts.taxonomy ?? EMBEDDED_TAXONOMY);
|
|
527
|
+
const types = (opts.taxonomy ?? EMBEDDED_TAXONOMY).types;
|
|
528
|
+
const docsRoot = repo.docs.root;
|
|
529
|
+
const absDocs = path.join(repo.cwd, docsRoot);
|
|
530
|
+
const configKeysAdded = await ensureDocsKey(repo, {}, dryRun);
|
|
531
|
+
const files = (await listDocFiles(repo)).filter((f) => topFolder(f) !== '_audit');
|
|
532
|
+
const legacy = readLegacyNames(repo);
|
|
533
|
+
const inGit = git(repo.cwd, ['rev-parse', '--is-inside-work-tree']) === 'true';
|
|
534
|
+
const out = [];
|
|
535
|
+
const offRule = [];
|
|
536
|
+
for (const rel of files) {
|
|
537
|
+
const abs = path.join(absDocs, rel);
|
|
538
|
+
const raw = await fs.readFile(abs, 'utf8');
|
|
539
|
+
const parsed = parseDoc(raw);
|
|
540
|
+
const base = path.posix.basename(rel);
|
|
541
|
+
const top = topFolder(rel);
|
|
542
|
+
const nameExempt = rel === 'index.md' || base === 'INDEX.md' || top === 'superpowers' || top === '_archive' || legacy.has(rel);
|
|
543
|
+
const nomeForaDaRegra = !nameExempt && !FILE_NAME_REGEX.test(base);
|
|
544
|
+
if (nomeForaDaRegra)
|
|
545
|
+
offRule.push(rel);
|
|
546
|
+
if (parsed.hasFrontmatter) {
|
|
547
|
+
const obs = [];
|
|
548
|
+
if (parsed.error)
|
|
549
|
+
obs.push(`Frontmatter inválido (${parsed.error}) — corrija à mão; nada foi alterado.`);
|
|
550
|
+
else if (parsed.data.product !== repo.slug)
|
|
551
|
+
obs.push(`Já tem frontmatter, sem \`product: ${repo.slug}\` — acrescente à mão.`);
|
|
552
|
+
out.push({ file: `${docsRoot}/${rel}`, acao: parsed.error ? 'frontmatter-invalido' : 'ja-tinha-frontmatter', inferencias: [], nomeForaDaRegra, observacoes: obs });
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
const inf = [];
|
|
556
|
+
const obs = [];
|
|
557
|
+
const fm = {};
|
|
558
|
+
const repoRel = `${docsRoot}/${rel}`;
|
|
559
|
+
// título
|
|
560
|
+
const h1 = firstH1(parsed.body);
|
|
561
|
+
const title = h1 ?? humanize(base);
|
|
562
|
+
fm.title = title;
|
|
563
|
+
inf.push({ campo: 'title', valor: title, origem: h1 ? 'primeiro H1' : 'nome do arquivo', confianca: h1 ? 'alta' : 'baixa' });
|
|
564
|
+
if (!h1)
|
|
565
|
+
obs.push('Sem H1: o validador vai pedir um H1 igual ao `title` (TI001).');
|
|
566
|
+
// descrição (só se der para tirar uma frase do primeiro parágrafo)
|
|
567
|
+
const desc = guessDescription(parsed.body);
|
|
568
|
+
if (desc) {
|
|
569
|
+
fm.description = desc;
|
|
570
|
+
inf.push({ campo: 'description', valor: desc, origem: 'primeira frase do texto', confianca: 'baixa' });
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
obs.push('Sem `description`: escreva uma frase de 40 a 200 caracteres (FM002).');
|
|
574
|
+
}
|
|
575
|
+
fm.product = repo.slug;
|
|
576
|
+
// pasta efetiva (protocolo ou legado) e destino sugerido
|
|
577
|
+
const effFolder = top === '' ? '' : FOLDER_RULES.some((r) => r.folder === top) ? top : LEGACY_FOLDERS[top.toLowerCase()];
|
|
578
|
+
const canon = canonicalDestination(rel, cats);
|
|
579
|
+
const plan = canon ?? planDestination({ rel, base, top, effFolder, cats });
|
|
580
|
+
const tg = plan.type;
|
|
581
|
+
fm.type = tg.type;
|
|
582
|
+
inf.push({ campo: 'type', valor: tg.type, origem: tg.why, confianca: tg.conf });
|
|
583
|
+
const cg = plan.category;
|
|
584
|
+
fm.category = cg.category;
|
|
585
|
+
fm.subcategory = cg.subcategory;
|
|
586
|
+
inf.push({ campo: 'category/subcategory', valor: `${cg.category}/${cg.subcategory}`, origem: cg.why, confianca: cg.conf });
|
|
587
|
+
// status
|
|
588
|
+
const family = types[tg.type] ?? 'vivo';
|
|
589
|
+
const status = top === '_archive' ? 'archived' : tg.type === 'adr' ? 'active' : FAMILY_STATUS[family];
|
|
590
|
+
fm.status = status;
|
|
591
|
+
inf.push({ campo: 'status', valor: status, origem: top === '_archive' ? 'pasta _archive/' : `família ${family}`, confianca: 'media' });
|
|
592
|
+
// datas
|
|
593
|
+
const nameDate = base.match(/(\d{4}-\d{2}-\d{2})/)?.[1];
|
|
594
|
+
const first = inGit ? firstCommitDate(repo.cwd, repoRel) : null;
|
|
595
|
+
const last = inGit ? lastCommitDate(repo.cwd, repoRel) : null;
|
|
596
|
+
const mtime = localIsoDate((await fs.stat(abs)).mtime);
|
|
597
|
+
let date = (family === 'registro' && nameDate) || first || nameDate || mtime;
|
|
598
|
+
const dateFromMtime = !(family === 'registro' && nameDate) && !first && !nameDate;
|
|
599
|
+
const dateInf = {
|
|
600
|
+
campo: 'date',
|
|
601
|
+
valor: date,
|
|
602
|
+
origem: family === 'registro' && nameDate ? 'data do nome' : first ? 'primeiro commit do arquivo' : nameDate ? 'data do nome' : 'data de modificação do arquivo',
|
|
603
|
+
confianca: first || nameDate ? 'alta' : 'baixa',
|
|
604
|
+
};
|
|
605
|
+
inf.push(dateInf);
|
|
606
|
+
if (family === 'vivo') {
|
|
607
|
+
const revised = bodyRevisedDate(parsed.body);
|
|
608
|
+
let updated = revised ?? last ?? mtime;
|
|
609
|
+
if (updated < date) {
|
|
610
|
+
// `updated` ≥ `date` (FM007): se a data de criação é só o mtime, ela é que cede.
|
|
611
|
+
if (dateFromMtime) {
|
|
612
|
+
date = updated;
|
|
613
|
+
dateInf.valor = date;
|
|
614
|
+
dateInf.origem = 'igual ao updated (sem histórico de criação)';
|
|
615
|
+
}
|
|
616
|
+
else
|
|
617
|
+
updated = date;
|
|
618
|
+
}
|
|
619
|
+
fm.updated = updated;
|
|
620
|
+
inf.push({
|
|
621
|
+
campo: 'updated',
|
|
622
|
+
valor: updated,
|
|
623
|
+
origem: revised ? 'metadado no corpo (ex.: _revisado: …_) — a linha do corpo continua lá' : last ? 'último commit do arquivo' : 'data de modificação do arquivo',
|
|
624
|
+
confianca: revised || last ? 'alta' : 'baixa',
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
fm.date = date;
|
|
628
|
+
fm.owner = 'produto';
|
|
629
|
+
const audienceFolder = plan.folder ?? effFolder ?? top;
|
|
630
|
+
const audience = audienceFolder === 'usuario' ? 'publico' : 'dev-produto';
|
|
631
|
+
fm.audience = audience;
|
|
632
|
+
inf.push({ campo: 'audience', valor: audience, origem: `padrão da pasta ${audienceFolder || 'raiz'}/`, confianca: 'media' });
|
|
633
|
+
if (tg.type === 'adr') {
|
|
634
|
+
const num = base.match(/adr[-_]?(\d+)/i)?.[1];
|
|
635
|
+
if (num)
|
|
636
|
+
fm.adr = Number.parseInt(num, 10);
|
|
637
|
+
}
|
|
638
|
+
if (top === '_archive') {
|
|
639
|
+
fm.archived_at = today;
|
|
640
|
+
fm.archived_from = rel.replace(/^_archive\//, '');
|
|
641
|
+
fm.archive_reason = 'Migrado para o protocolo de docs: já estava no arquivo morto.';
|
|
642
|
+
obs.push('Carimbo de arquivamento inferido — confira `archived_at` e `archive_reason`.');
|
|
643
|
+
}
|
|
644
|
+
fm.tags = ['migrado'];
|
|
645
|
+
if (h1 && h1 !== title)
|
|
646
|
+
obs.push('H1 diferente do title.');
|
|
647
|
+
let sugestaoDestino;
|
|
648
|
+
if (plan.destinoDocs) {
|
|
649
|
+
sugestaoDestino = `${docsRoot}/${plan.destinoDocs}`;
|
|
650
|
+
}
|
|
651
|
+
else if (plan.destino) {
|
|
652
|
+
sugestaoDestino = plan.destino;
|
|
653
|
+
}
|
|
654
|
+
else if (plan.folder !== undefined) {
|
|
655
|
+
const newBase = top === 'superpowers' || top === '_archive' || base === 'index.md'
|
|
656
|
+
? base
|
|
657
|
+
: suggestedName(base, tg.type, family, date);
|
|
658
|
+
const dest = [plan.folder, plan.keepSubPath ? rel.split('/').slice(1, -1).join('/') : '', newBase].filter(Boolean).join('/');
|
|
659
|
+
if (dest !== rel)
|
|
660
|
+
sugestaoDestino = `${docsRoot}/${dest}`;
|
|
661
|
+
}
|
|
662
|
+
if (plan.nota)
|
|
663
|
+
obs.push(plan.nota);
|
|
664
|
+
const eol = parsed.eol;
|
|
665
|
+
const block = serializeFrontmatter(fm, eol);
|
|
666
|
+
const textNoBom = parsed.bom ? raw.slice(1) : raw;
|
|
667
|
+
const next = `${parsed.bom ? BOM : ''}${block}${eol}${eol}${textNoBom.replace(/^(\r?\n)+/, '')}`;
|
|
668
|
+
if (!dryRun) {
|
|
669
|
+
const tmp = `${abs}.${process.pid}.tmp`;
|
|
670
|
+
await fs.writeFile(tmp, next, 'utf8');
|
|
671
|
+
await fs.rename(tmp, abs);
|
|
672
|
+
}
|
|
673
|
+
out.push({ file: repoRel, acao: 'frontmatter-adicionado', inferencias: inf, sugestaoDestino, nomeForaDaRegra, observacoes: obs });
|
|
674
|
+
}
|
|
675
|
+
// _nomes-legados.json — só cria se não existe (a lista só diminui)
|
|
676
|
+
let legacyNamesFile;
|
|
677
|
+
const legacyPath = path.join(absDocs, '_nomes-legados.json');
|
|
678
|
+
if (offRule.length) {
|
|
679
|
+
if (!fsSync.existsSync(legacyPath)) {
|
|
680
|
+
if (!dryRun) {
|
|
681
|
+
await fs.writeFile(legacyPath, JSON.stringify({
|
|
682
|
+
protocolVersion: 1,
|
|
683
|
+
gerado_em: today,
|
|
684
|
+
nota: 'Nomes fora da regra do protocolo, tolerados pelo validador. A lista só diminui: arquivo renomeado ou arquivado sai e não volta.',
|
|
685
|
+
arquivos: offRule,
|
|
686
|
+
}, null, 2) + '\n', 'utf8');
|
|
687
|
+
}
|
|
688
|
+
legacyNamesFile = { path: `${docsRoot}/_nomes-legados.json`, created: true, count: offRule.length };
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
legacyNamesFile = { path: `${docsRoot}/_nomes-legados.json`, created: false, count: offRule.length };
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
const withApi = productExposesApi(repo);
|
|
695
|
+
const faltandoPrimeiroDia = ['README.md', 'CHANGELOG.md', `${docsRoot}/index.md`, `${docsRoot}/produto/VISAO_DO_PRODUTO.md`, `${docsRoot}/arquitetura/ARQUITETURA.md`, `${docsRoot}/runbooks/RUNBOOK_DEPLOY.md`, ...(withApi ? [`${docsRoot}/api/API_V1.md`] : [])].filter((p) => !fsSync.existsSync(path.join(repo.cwd, p)));
|
|
696
|
+
const result = { slug: repo.slug, docsRoot, files: out, legacyNamesFile, configKeysAdded, faltandoPrimeiroDia, dryRun };
|
|
697
|
+
const reportRel = `${docsRoot}/_audit/MIGRACAO_DOCS_${today}.md`;
|
|
698
|
+
if (!dryRun && fsSync.existsSync(absDocs)) {
|
|
699
|
+
const reportAbs = path.join(repo.cwd, reportRel);
|
|
700
|
+
await fs.mkdir(path.dirname(reportAbs), { recursive: true });
|
|
701
|
+
await fs.writeFile(reportAbs, renderMigrationReport(repo, result, today), 'utf8');
|
|
702
|
+
result.reportPath = reportRel;
|
|
703
|
+
}
|
|
704
|
+
return result;
|
|
705
|
+
}
|
|
706
|
+
export function renderMigrationReport(repo, r, today) {
|
|
707
|
+
const fm = serializeFrontmatter({
|
|
708
|
+
title: `Migração dos docs para o protocolo — ${today}`,
|
|
709
|
+
description: 'Frontmatter inferido por arquivo, com a origem e a confiança de cada valor, e o que falta decidir à mão.',
|
|
710
|
+
product: repo.slug,
|
|
711
|
+
category: 'Documentação',
|
|
712
|
+
subcategory: 'relatorios-automaticos',
|
|
713
|
+
type: 'relatorio',
|
|
714
|
+
status: 'historical',
|
|
715
|
+
date: today,
|
|
716
|
+
owner: 'produto',
|
|
717
|
+
audience: 'dev-produto',
|
|
718
|
+
});
|
|
719
|
+
const added = r.files.filter((f) => f.acao === 'frontmatter-adicionado');
|
|
720
|
+
const lines = [
|
|
721
|
+
fm,
|
|
722
|
+
'',
|
|
723
|
+
`# Migração dos docs para o protocolo — ${today}`,
|
|
724
|
+
'',
|
|
725
|
+
'> Gerado por `neetru docs init --migrar`. Nada foi movido nem renomeado, e o texto dos docs não mudou: só entrou frontmatter nos arquivos que não tinham.',
|
|
726
|
+
'',
|
|
727
|
+
'## Resumo',
|
|
728
|
+
'',
|
|
729
|
+
`- Arquivos analisados: ${r.files.length}`,
|
|
730
|
+
`- Frontmatter acrescentado: ${added.length}`,
|
|
731
|
+
`- Já tinham frontmatter (intocados): ${r.files.filter((f) => f.acao === 'ja-tinha-frontmatter').length}`,
|
|
732
|
+
`- Frontmatter inválido (intocados, corrigir à mão): ${r.files.filter((f) => f.acao === 'frontmatter-invalido').length}`,
|
|
733
|
+
`- Nomes fora da regra: ${r.files.filter((f) => f.nomeForaDaRegra).length}${r.legacyNamesFile ? ` (${r.legacyNamesFile.created ? 'listados em' : 'o arquivo já existia e não foi alterado:'} ${r.legacyNamesFile.path})` : ''}`,
|
|
734
|
+
'',
|
|
735
|
+
'## Achados',
|
|
736
|
+
'',
|
|
737
|
+
'### Revisar primeiro (confiança baixa)',
|
|
738
|
+
'',
|
|
739
|
+
];
|
|
740
|
+
const low = added.flatMap((f) => f.inferencias.filter((i) => i.confianca === 'baixa').map((i) => ({ f, i })));
|
|
741
|
+
if (!low.length)
|
|
742
|
+
lines.push('Nenhuma inferência de confiança baixa.', '');
|
|
743
|
+
else {
|
|
744
|
+
lines.push('| Arquivo | Campo | Valor | Origem |', '|---|---|---|---|');
|
|
745
|
+
for (const { f, i } of low)
|
|
746
|
+
lines.push(`| ${f.file} | ${i.campo} | ${i.valor.replace(/\|/g, '\\|')} | ${i.origem} |`);
|
|
747
|
+
lines.push('');
|
|
748
|
+
}
|
|
749
|
+
lines.push('### Para onde mover (decisão humana)', '');
|
|
750
|
+
const moves = r.files.filter((f) => f.sugestaoDestino);
|
|
751
|
+
if (!moves.length)
|
|
752
|
+
lines.push('Nenhuma mudança de lugar sugerida.', '');
|
|
753
|
+
else {
|
|
754
|
+
lines.push('| Hoje | Sugestão pelo protocolo |', '|---|---|');
|
|
755
|
+
for (const f of moves)
|
|
756
|
+
lines.push(`| ${f.file} | ${f.sugestaoDestino} |`);
|
|
757
|
+
lines.push('', 'Ao mover, use `git mv` e corrija os links de entrada (`neetru docs check` acusa os quebrados como LK001). Quem sai do lugar e ganha nome na regra deve sair também do `_nomes-legados.json`.', '');
|
|
758
|
+
}
|
|
759
|
+
lines.push('### Todas as inferências', '');
|
|
760
|
+
for (const f of r.files) {
|
|
761
|
+
lines.push(`#### ${f.file}`, '');
|
|
762
|
+
if (f.acao !== 'frontmatter-adicionado') {
|
|
763
|
+
lines.push(f.acao === 'ja-tinha-frontmatter' ? '- Já tinha frontmatter: não foi alterado.' : '- Frontmatter inválido: não foi alterado.');
|
|
764
|
+
}
|
|
765
|
+
for (const i of f.inferencias)
|
|
766
|
+
lines.push(`- \`${i.campo}\`: ${i.valor} — ${i.origem} (confiança ${i.confianca})`);
|
|
767
|
+
for (const o of f.observacoes)
|
|
768
|
+
lines.push(`- Observação: ${o}`);
|
|
769
|
+
lines.push('');
|
|
770
|
+
}
|
|
771
|
+
lines.push('## Recomendações', '');
|
|
772
|
+
lines.push('1. Revise as inferências de confiança baixa e as descrições (tiradas da primeira frase do texto).');
|
|
773
|
+
lines.push('2. Mova os arquivos conforme a tabela acima, num PR só de mudança de lugar.');
|
|
774
|
+
if (r.faltandoPrimeiroDia.length) {
|
|
775
|
+
lines.push(`3. Depois de mover, complete o mínimo do primeiro dia com \`neetru docs init\` (não sobrescreve nada). Falta hoje: ${r.faltandoPrimeiroDia.join(', ')}.`);
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
lines.push('3. O mínimo do primeiro dia já está presente.');
|
|
779
|
+
}
|
|
780
|
+
lines.push('4. Rode `neetru docs check` e corrija os erros antes de abrir o PR.', '');
|
|
781
|
+
return lines.join('\n');
|
|
782
|
+
}
|
|
783
|
+
// ─── docs new ───────────────────────────────────────────────────────────────
|
|
784
|
+
/** Aliases de `docs new` que trazem os modelos de base do zelo. */
|
|
785
|
+
export const NEW_ALIASES = {
|
|
786
|
+
rfc: { type: 'plano', variant: 'rfc' },
|
|
787
|
+
spec: { type: 'plano', variant: 'spec' },
|
|
788
|
+
politica: { type: 'norma', variant: 'politica' },
|
|
789
|
+
ameacas: { type: 'referencia', variant: 'ameacas' },
|
|
790
|
+
'modelo-de-ameacas': { type: 'referencia', variant: 'ameacas' },
|
|
791
|
+
};
|
|
792
|
+
export const NEW_TYPES = ['guia', 'referencia', 'arquitetura', 'adr', 'runbook', 'norma', 'plano', 'relatorio', 'incidente', 'ata'];
|
|
793
|
+
export class DocsNewError extends Error {
|
|
794
|
+
code;
|
|
795
|
+
constructor(code, message) {
|
|
796
|
+
super(message);
|
|
797
|
+
this.code = code;
|
|
798
|
+
this.name = 'DocsNewError';
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/** Nome de arquivo pela regra do §6/§8: sem acento, MAIÚSCULAS, prefixo do tipo, data em registro. */
|
|
802
|
+
export function fileNameFor(type, title, opts = {}) {
|
|
803
|
+
// datas no título não entram no assunto (a data do registro vai no fim do nome)
|
|
804
|
+
let words = toFileWords(title.replace(/\d{4}-\d{2}(-\d{2})?/g, ' '));
|
|
805
|
+
let prefix = TYPE_PREFIX[type] ?? '';
|
|
806
|
+
// Prefixos alternativos aceitos pelo §8.1: o título que já começa com um
|
|
807
|
+
// deles vira o prefixo ("Auditoria de UX…" → AUDITORIA_UX_…, "Protocolo de…"
|
|
808
|
+
// → PROTOCOLO_…). Os demais só não se repetem ("Guia de X" → GUIA_X).
|
|
809
|
+
const altPrefixes = {
|
|
810
|
+
GUIA: ['GUIA'], RUNBOOK: ['RUNBOOK', 'SETUP'], ARQUITETURA: ['ARQUITETURA'], POLITICA: ['POLITICA', 'PROTOCOLO'],
|
|
811
|
+
PLANO: ['PLANO'], RELATORIO: ['RELATORIO', 'AUDITORIA', 'REVIEW', 'ANALISE', 'INVENTARIO', 'ACHADO', 'BACKLOG'],
|
|
812
|
+
INCIDENTE: ['INCIDENTE'], ATA: ['ATA', 'DECISOES'], ADR: ['ADR'],
|
|
813
|
+
};
|
|
814
|
+
const alts = altPrefixes[prefix] ?? [];
|
|
815
|
+
if (words.length > 1 && alts.includes(words[0])) {
|
|
816
|
+
if (prefix !== 'ADR')
|
|
817
|
+
prefix = words[0];
|
|
818
|
+
words = words.slice(1);
|
|
819
|
+
while (words.length > 1 && STOPWORDS.has(words[0]))
|
|
820
|
+
words = words.slice(1);
|
|
821
|
+
}
|
|
822
|
+
else if (words.length > 1 && prefix === 'POLITICA' && words[0] === 'NORMA') {
|
|
823
|
+
words = words.slice(1);
|
|
824
|
+
while (words.length > 1 && STOPWORDS.has(words[0]))
|
|
825
|
+
words = words.slice(1);
|
|
826
|
+
}
|
|
827
|
+
if (words.length > 6) {
|
|
828
|
+
const noStop = words.filter((w) => !STOPWORDS.has(w));
|
|
829
|
+
words = (noStop.length ? noStop : words).slice(0, 6);
|
|
830
|
+
}
|
|
831
|
+
if (!words.length)
|
|
832
|
+
words = ['DOC'];
|
|
833
|
+
if (/^\d/.test(words[0]) && !prefix)
|
|
834
|
+
words = ['DOC', ...words];
|
|
835
|
+
const subject = words.join('_');
|
|
836
|
+
if (type === 'adr')
|
|
837
|
+
return `ADR-${String(opts.adrNumber ?? 1).padStart(4, '0')}_${subject}.md`;
|
|
838
|
+
const head = prefix ? `${prefix}_${subject}` : subject;
|
|
839
|
+
const fam = EMBEDDED_TAXONOMY.types[type];
|
|
840
|
+
return fam === 'registro' ? `${head}_${opts.date}.md` : `${head}.md`;
|
|
841
|
+
}
|
|
842
|
+
function nextAdrNumber(absDecisoes) {
|
|
843
|
+
let max = 0;
|
|
844
|
+
try {
|
|
845
|
+
for (const f of fsSync.readdirSync(absDecisoes)) {
|
|
846
|
+
const m = f.match(/^ADR-(\d{4})_/i);
|
|
847
|
+
if (m)
|
|
848
|
+
max = Math.max(max, Number.parseInt(m[1], 10));
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
catch {
|
|
852
|
+
/* pasta não existe */
|
|
853
|
+
}
|
|
854
|
+
return max + 1;
|
|
855
|
+
}
|
|
856
|
+
/** `neetru docs new <tipo> "<título>"`. Lança `DocsNewError` com mensagem PT-BR. */
|
|
857
|
+
export async function createProductDoc(repo, tipo, titulo, opts = {}) {
|
|
858
|
+
const taxonomy = opts.taxonomy ?? EMBEDDED_TAXONOMY;
|
|
859
|
+
const cats = productCategories(taxonomy);
|
|
860
|
+
const today = opts.today ?? todayIso();
|
|
861
|
+
const key = tipo.trim().toLowerCase();
|
|
862
|
+
const alias = NEW_ALIASES[key];
|
|
863
|
+
const type = alias?.type ?? key;
|
|
864
|
+
if (!NEW_TYPES.includes(type)) {
|
|
865
|
+
throw new DocsNewError('invalid_type', `Tipo "${tipo}" não é criado por \`docs new\`. Use: ${NEW_TYPES.join(', ')} — ou os modelos ${Object.keys(NEW_ALIASES).filter((k) => k !== 'modelo-de-ameacas').join(', ')}.`);
|
|
866
|
+
}
|
|
867
|
+
const cleanTitle = titulo.trim().replace(/\s+/g, ' ').replace(/\.$/, '');
|
|
868
|
+
if (cleanTitle.length < 3)
|
|
869
|
+
throw new DocsNewError('invalid_title', 'Título vazio ou curto demais.');
|
|
870
|
+
const recordDate = opts.data ?? today;
|
|
871
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(recordDate))
|
|
872
|
+
throw new DocsNewError('invalid_date', `--data "${opts.data}" fora do formato AAAA-MM-DD.`);
|
|
873
|
+
// categoria
|
|
874
|
+
if (opts.categoria && taxonomy.coreOnlyCategories.includes(opts.categoria)) {
|
|
875
|
+
throw new DocsNewError('core_only_category', `A categoria "${opts.categoria}" só existe no Core (PR003).`);
|
|
876
|
+
}
|
|
877
|
+
if (opts.categoria && !cats[opts.categoria]) {
|
|
878
|
+
throw new DocsNewError('invalid_category', `Categoria "${opts.categoria}" fora da lista: ${Object.keys(cats).join(', ')}.`);
|
|
879
|
+
}
|
|
880
|
+
// pasta
|
|
881
|
+
let folder = TYPE_DEFAULT_FOLDER[type];
|
|
882
|
+
if (alias?.variant === 'ameacas' || alias?.variant === 'politica')
|
|
883
|
+
folder = 'seguranca';
|
|
884
|
+
const catFolder = opts.categoria ? CATEGORY_FOLDER[opts.categoria] : undefined;
|
|
885
|
+
if (catFolder) {
|
|
886
|
+
const r = FOLDER_RULES.find((x) => x.folder === catFolder);
|
|
887
|
+
if (r && r.types !== 'qualquer' && r.types.includes(type))
|
|
888
|
+
folder = catFolder;
|
|
889
|
+
}
|
|
890
|
+
const rule = FOLDER_RULES.find((x) => x.folder === folder);
|
|
891
|
+
let categoria = opts.categoria ?? rule.category;
|
|
892
|
+
let subcategoria = opts.subcategoria;
|
|
893
|
+
let categoriaPadrao = !opts.categoria;
|
|
894
|
+
if (alias?.variant === 'ameacas' && !opts.categoria) {
|
|
895
|
+
categoria = 'Segurança';
|
|
896
|
+
subcategoria = subcategoria ?? 'vulnerabilidades';
|
|
897
|
+
}
|
|
898
|
+
if (!subcategoria)
|
|
899
|
+
subcategoria = categoria === rule.category ? rule.subcategory : cats[categoria][0];
|
|
900
|
+
if (!cats[categoria]?.includes(subcategoria)) {
|
|
901
|
+
throw new DocsNewError('invalid_subcategory', `Subcategoria "${subcategoria}" não pertence a "${categoria}". Aceitas: ${cats[categoria].join(', ')}.`);
|
|
902
|
+
}
|
|
903
|
+
if (opts.subcategoria || alias?.variant === 'ameacas')
|
|
904
|
+
categoriaPadrao = false;
|
|
905
|
+
const audience = opts.audience ?? rule.audience;
|
|
906
|
+
if (!taxonomy.audiences.includes(audience)) {
|
|
907
|
+
throw new DocsNewError('invalid_audience', `--audience "${audience}" fora da lista: ${taxonomy.audiences.join(', ')}.`);
|
|
908
|
+
}
|
|
909
|
+
const family = taxonomy.types[type];
|
|
910
|
+
const absDocs = path.join(repo.cwd, repo.docs.root);
|
|
911
|
+
let adrNumber;
|
|
912
|
+
let finalTitle = cleanTitle;
|
|
913
|
+
if (type === 'adr') {
|
|
914
|
+
adrNumber = nextAdrNumber(path.join(absDocs, 'decisoes'));
|
|
915
|
+
finalTitle = `ADR-${String(adrNumber).padStart(4, '0')} — ${cleanTitle.replace(/^ADR[-\s]?\d*\s*[—:-]?\s*/i, '')}`;
|
|
916
|
+
}
|
|
917
|
+
else if (family === 'registro' && !/\d{4}-\d{2}(-\d{2})?$/.test(cleanTitle)) {
|
|
918
|
+
finalTitle = `${cleanTitle} — ${recordDate}`;
|
|
919
|
+
}
|
|
920
|
+
const name = fileNameFor(type, cleanTitle.replace(/\s+—\s+\d{4}-\d{2}(-\d{2})?$/, ''), { date: recordDate, adrNumber });
|
|
921
|
+
const rel = `${folder}/${name}`;
|
|
922
|
+
const abs = path.join(absDocs, rel);
|
|
923
|
+
if (fsSync.existsSync(abs)) {
|
|
924
|
+
throw new DocsNewError('exists', `${repo.docs.root}/${rel} já existe — \`docs new\` nunca sobrescreve. Escolha outro título.`);
|
|
925
|
+
}
|
|
926
|
+
const status = type === 'adr' ? 'proposed' : 'draft';
|
|
927
|
+
let sections = TYPE_MODELS[type] ?? [];
|
|
928
|
+
let extra;
|
|
929
|
+
if (type === 'adr')
|
|
930
|
+
extra = ADR_EXTRA;
|
|
931
|
+
if (alias?.variant === 'rfc')
|
|
932
|
+
extra = PLANO_RFC_EXTRA;
|
|
933
|
+
if (alias?.variant === 'spec' || (type === 'plano' && !alias))
|
|
934
|
+
extra = PLANO_SPEC_EXTRA;
|
|
935
|
+
if (alias?.variant === 'politica')
|
|
936
|
+
extra = NORMA_POLITICA_EXTRA;
|
|
937
|
+
if (alias?.variant === 'ameacas') {
|
|
938
|
+
sections = THREAT_MODEL_SECTIONS;
|
|
939
|
+
extra = THREAT_EXTRA;
|
|
940
|
+
}
|
|
941
|
+
const description = opts.descricao?.trim() || 'Escreva aqui, em uma frase de 40 a 200 caracteres, o que este doc é e quando consultá-lo.';
|
|
942
|
+
const content = renderDoc({
|
|
943
|
+
title: finalTitle,
|
|
944
|
+
description,
|
|
945
|
+
product: repo.slug,
|
|
946
|
+
category: categoria,
|
|
947
|
+
subcategory: subcategoria,
|
|
948
|
+
type,
|
|
949
|
+
status,
|
|
950
|
+
date: family === 'registro' ? recordDate : today,
|
|
951
|
+
updated: family === 'vivo' ? today : undefined,
|
|
952
|
+
audience,
|
|
953
|
+
adr: adrNumber,
|
|
954
|
+
lead: audience === 'publico' ? 'Para quem usa o produto.' : 'Para a equipe do produto. Rascunho.',
|
|
955
|
+
sections,
|
|
956
|
+
extra,
|
|
957
|
+
});
|
|
958
|
+
if (!opts.dryRun) {
|
|
959
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
960
|
+
await fs.writeFile(abs, content, { encoding: 'utf8', flag: 'wx' });
|
|
961
|
+
}
|
|
962
|
+
return {
|
|
963
|
+
path: `${repo.docs.root}/${rel}`,
|
|
964
|
+
type,
|
|
965
|
+
title: finalTitle,
|
|
966
|
+
category: categoria,
|
|
967
|
+
subcategory: subcategoria,
|
|
968
|
+
audience,
|
|
969
|
+
status,
|
|
970
|
+
categoriaPadrao,
|
|
971
|
+
dryRun: !!opts.dryRun,
|
|
972
|
+
content,
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
//# sourceMappingURL=ops.js.map
|