@fontdata/ai-harness 2.2.0 → 2.3.1
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/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -2
- package/dist/cli.js +1 -0
- package/dist/contratos/artefatos.js +1 -1
- package/dist/doctor/checks.js +3 -0
- package/dist/doctor/migracao.js +97 -0
- package/dist/migrar/migrar.js +74 -3
- package/dist/trabalhos/conflitos.js +4 -2
- package/package.json +1 -1
- package/templates/hooks/comentarios/check.mjs +178 -37
- package/templates/hooks/lib/rastro.mjs +5 -2
package/README.md
CHANGED
|
@@ -108,9 +108,9 @@ duas vezes.
|
|
|
108
108
|
| Subcomando | O que faz |
|
|
109
109
|
|---|---|
|
|
110
110
|
| `init [caminho]` | Instala comandos, skills, agents e hooks no projeto |
|
|
111
|
-
| `doctor [caminho]` | Diagnostica instalação, contrato dos documentos, rastro, bloqueios antigos e versão do Node |
|
|
111
|
+
| `doctor [caminho]` | Diagnostica instalação, contrato dos documentos, rastro, bloqueios antigos, citações às pastas antigas de `docs/` no código e versão do Node |
|
|
112
112
|
| `update [caminho]` | Atualiza o que foi instalado sem sobrescrever alterações locais (`--check`, `--yes`, `--forcar`) |
|
|
113
|
-
| `migrar [caminho]` | Move os documentos do layout anterior para `docs/trabalhos/<id>/` (`--yes`) |
|
|
113
|
+
| `migrar [caminho]` | Move os documentos do layout anterior para `docs/trabalhos/<id>/` (`--yes`); `--reclassificar` corrige planos migrados que ficaram em execução |
|
|
114
114
|
| `memoria [caminho]` | Índice de quem mexeu em cada arquivo (`--arquivo <caminho>`, `--regressao <id>`) |
|
|
115
115
|
| `evento <evento>` | Grava no rastro do trabalho (usado por fluxos e agents) |
|
|
116
116
|
| `conflitos <id>` | Aponta trabalhos em execução que mexem nos mesmos arquivos; `--aguardar` espera liberar (usado pelo `/executar-plan`) |
|
package/dist/cli.js
CHANGED
|
@@ -48,6 +48,7 @@ Gerenciar:
|
|
|
48
48
|
doctor [caminho] Diagnostica a instalação (arquivos, lock, skills, agents, hooks, Node)
|
|
49
49
|
update [caminho] Atualiza o que foi instalado: --check só lista, --yes aplica, --forcar sobrescreve conflitos
|
|
50
50
|
migrar [caminho] Move docs/specs, plans, bugs e investigacoes para docs/trabalhos/<id>/ (--yes aplica)
|
|
51
|
+
--reclassificar: planos já migrados em em-execucao viram concluido ou legado
|
|
51
52
|
comentarios [caminho] Instala o hook de comentários e a catraca do legado (--yes aplica)
|
|
52
53
|
memoria [caminho] Índice de quem mexeu em cada arquivo (--arquivo <caminho>, --regressao <id do bug>)
|
|
53
54
|
evento <evento> Grava no rastro do trabalho (--trabalho, --task, --nome, --resultado, --dados '{json}')
|
|
@@ -19,7 +19,7 @@ export const ARTIFACT_TYPES = {
|
|
|
19
19
|
};
|
|
20
20
|
export const STATUS_BY_TYPE = {
|
|
21
21
|
spec: ['rascunho', 'aguardando-aprovacao', 'aprovado', 'substituido'],
|
|
22
|
-
plano: ['rascunho', 'aguardando-aprovacao', 'em-execucao', 'concluido', 'cancelado'],
|
|
22
|
+
plano: ['rascunho', 'aguardando-aprovacao', 'em-execucao', 'concluido', 'cancelado', 'legado'],
|
|
23
23
|
decisoes: ['ativo'],
|
|
24
24
|
bloqueios: ['aberto', 'resolvido'],
|
|
25
25
|
relatorio_tecnico: ['rascunho', 'final'],
|
package/dist/doctor/checks.js
CHANGED
|
@@ -8,6 +8,7 @@ import { validateEventLog } from '../contratos/eventos.js';
|
|
|
8
8
|
import { hashContent, LOCK_PATH, LOCK_VERSION } from '../install/lock.js';
|
|
9
9
|
import { SETTINGS_PATH } from '../install/settings.js';
|
|
10
10
|
import { adoptLegacyInstall } from '../update/legado.js';
|
|
11
|
+
import { checkLegacyDocsCitations, checkMigratedPlansRunning } from './migracao.js';
|
|
11
12
|
const MIN_NODE_MAJOR = 22;
|
|
12
13
|
const PLUGIN_ID = 'fontdata@ai-harness';
|
|
13
14
|
const HARNESS_HOOKS_DIR = '.claude/hooks/ai-harness/';
|
|
@@ -413,6 +414,8 @@ export function runDoctor(root, options = {}) {
|
|
|
413
414
|
...checkStaleBlocks(root, options.now ?? new Date(), options.staleBlockDays ?? config.bloqueios.dias_para_aviso),
|
|
414
415
|
...checkEventLogs(root),
|
|
415
416
|
...checkCompletedTasks(root),
|
|
417
|
+
...checkMigratedPlansRunning(root),
|
|
418
|
+
...checkLegacyDocsCitations(root),
|
|
416
419
|
];
|
|
417
420
|
}
|
|
418
421
|
export function formatFindings(findings) {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { extname, join } from 'node:path';
|
|
3
|
+
import { readArtifact, WORKS_DIR } from '../contratos/artefatos.js';
|
|
4
|
+
/** As mesmas extensões e pastas ignoradas do hook de comentários (templates/hooks/comentarios/check.mjs). */
|
|
5
|
+
const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.cs', '.php', '.pas', '.dpr', '.sql', '.sh']);
|
|
6
|
+
const SKIPPED_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage', 'out', 'vendor', 'generated', '__generated__']);
|
|
7
|
+
const MAX_FILE_BYTES = 1_000_000;
|
|
8
|
+
const LEGACY_CITATION = /\b[Dd]ocs\/(?:specs|plans|bugs|investigacoes)\//g;
|
|
9
|
+
const TEST_FILE = /\.(test|spec)\.[jt]sx?$/;
|
|
10
|
+
const COMMENT_LINE = /^\s*(\*|\(\*|\{(?!\$))/;
|
|
11
|
+
const COMMENT_MARKER = /\/\/|\/\*|#|--/;
|
|
12
|
+
const EXAMPLES_SHOWN = 5;
|
|
13
|
+
function codeFiles(root, dir = '', found = []) {
|
|
14
|
+
let entries;
|
|
15
|
+
try {
|
|
16
|
+
entries = readdirSync(join(root, dir), { withFileTypes: true });
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return found;
|
|
20
|
+
}
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
const rel = dir ? `${dir}/${entry.name}` : entry.name;
|
|
23
|
+
if (entry.isDirectory()) {
|
|
24
|
+
if (!entry.name.startsWith('.') && !SKIPPED_DIRS.has(entry.name))
|
|
25
|
+
codeFiles(root, rel, found);
|
|
26
|
+
}
|
|
27
|
+
else if (entry.isFile() && CODE_EXTENSIONS.has(extname(entry.name)) && !entry.name.endsWith('.d.ts') && !TEST_FILE.test(entry.name)) {
|
|
28
|
+
found.push(rel);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* O `migrar` reescreve os links dos documentos, mas não toca no código: a citação às pastas antigas num comentário
|
|
35
|
+
* vira caminho quebrado, e a rule de comentários já proíbe citar docs/ no código.
|
|
36
|
+
*/
|
|
37
|
+
export function checkLegacyDocsCitations(root) {
|
|
38
|
+
const places = [];
|
|
39
|
+
let total = 0;
|
|
40
|
+
for (const rel of codeFiles(root).sort()) {
|
|
41
|
+
const file = join(root, rel);
|
|
42
|
+
if (statSync(file).size > MAX_FILE_BYTES)
|
|
43
|
+
continue;
|
|
44
|
+
const lines = readFileSync(file, 'utf8').split('\n');
|
|
45
|
+
lines.forEach((line, index) => {
|
|
46
|
+
// Só a citação em comentário: a mesma string num literal de código é dado, não rastreabilidade.
|
|
47
|
+
const inComment = COMMENT_LINE.test(line)
|
|
48
|
+
? [...line.matchAll(LEGACY_CITATION)]
|
|
49
|
+
: [...line.matchAll(LEGACY_CITATION)].filter((match) => COMMENT_MARKER.test(line.slice(0, match.index)));
|
|
50
|
+
const count = inComment.length;
|
|
51
|
+
if (!count)
|
|
52
|
+
return;
|
|
53
|
+
total += count;
|
|
54
|
+
places.push(`${rel}:${index + 1}`);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (!total)
|
|
58
|
+
return [];
|
|
59
|
+
const files = new Set(places.map((place) => place.slice(0, place.lastIndexOf(':')))).size;
|
|
60
|
+
const examples = places.slice(0, EXAMPLES_SHOWN).join(', ');
|
|
61
|
+
const more = places.length > EXAMPLES_SHOWN ? ` e mais ${places.length - EXAMPLES_SHOWN}` : '';
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
id: 'citacao-docs-em-codigo',
|
|
65
|
+
severidade: 'aviso',
|
|
66
|
+
mensagem: `${total} citação(ões) em ${files} arquivo(s) de código apontam para docs/specs, docs/plans, docs/bugs ou ` +
|
|
67
|
+
`docs/investigacoes, pastas que o migrar substituiu por docs/trabalhos/: ${examples}${more}.`,
|
|
68
|
+
correcao: 'Remova a citação do comentário (o vínculo fica no commit ou em docs/trabalhos/), como pede a rule ' +
|
|
69
|
+
'.claude/rules/comentarios.md. Arquivo tolerado pela catraca pode ser limpo aos poucos.',
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
/** Planos migrados antes da 2.3.0 ficaram `em-execucao` e aparecem como ativos em todo `conflitos`. */
|
|
74
|
+
export function checkMigratedPlansRunning(root) {
|
|
75
|
+
const dir = join(root, WORKS_DIR);
|
|
76
|
+
if (!existsSync(dir))
|
|
77
|
+
return [];
|
|
78
|
+
const stuck = readdirSync(dir).filter((id) => {
|
|
79
|
+
const rel = `docs/trabalhos/${id}/PLAN.md`;
|
|
80
|
+
if (!existsSync(join(root, rel)))
|
|
81
|
+
return false;
|
|
82
|
+
const { data } = readArtifact(rel, readFileSync(join(root, rel), 'utf8'));
|
|
83
|
+
const arquivos = Array.isArray(data?.arquivos) ? data.arquivos : [];
|
|
84
|
+
return data?.migrado === true && data.status === 'em-execucao' && !arquivos.length;
|
|
85
|
+
});
|
|
86
|
+
if (!stuck.length)
|
|
87
|
+
return [];
|
|
88
|
+
return [
|
|
89
|
+
{
|
|
90
|
+
id: 'plano-migrado-em-execucao',
|
|
91
|
+
severidade: 'aviso',
|
|
92
|
+
mensagem: `${stuck.length} plano(s) migrado(s) do layout antigo estão como em-execucao sem declarar arquivos: ${stuck.slice(0, EXAMPLES_SHOWN).join(', ')}${stuck.length > EXAMPLES_SHOWN ? ' …' : ''}.`,
|
|
93
|
+
correcao: 'Rode `npx @fontdata/ai-harness migrar --reclassificar --yes`: plano sem caixa aberta vira concluido, e o ' +
|
|
94
|
+
'restante vira legado, que não conta como trabalho ativo.',
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
}
|
package/dist/migrar/migrar.js
CHANGED
|
@@ -179,6 +179,10 @@ function rootCauseOf(body) {
|
|
|
179
179
|
return 'hipotese';
|
|
180
180
|
return UNCONFIRMED.test(body.slice(label.index, label.index + 600)) ? 'hipotese' : 'comprovada';
|
|
181
181
|
}
|
|
182
|
+
/** Plano antigo com caixa aberta não está necessariamente em execução: `legado` não conta como trabalho ativo. */
|
|
183
|
+
function legacyPlanStatus(body) {
|
|
184
|
+
return OPEN_BOX.test(body) ? 'legado' : 'concluido';
|
|
185
|
+
}
|
|
182
186
|
const TASK_HEADING = /^(#{2,6})\s*(?:(?:task|tarefa)\s+|T-?)(\d+)\.(\d+)\b\s*[—–:.-]*\s*(.*)$/i;
|
|
183
187
|
const TASK_ITEM = /^\s*[-*]\s+\[([ xX])\]\s+\*{0,2}(?:(?:task|tarefa)\s+|T-?)(\d+)\.(\d+)\b\s*[—–:.-]*\s*(.*)$/i;
|
|
184
188
|
const ANY_HEADING = /^(#{1,6})\s/;
|
|
@@ -256,7 +260,7 @@ function contractFrontmatter(content, move, today) {
|
|
|
256
260
|
const existing = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
257
261
|
const body = match ? content.slice(match[0].length) : content;
|
|
258
262
|
const heading = /^#\s+(.+)$/m.exec(body)?.[1]?.trim();
|
|
259
|
-
const status = move.tipo === 'spec' ? 'aprovado' : move.tipo === 'plano' ? (
|
|
263
|
+
const status = move.tipo === 'spec' ? 'aprovado' : move.tipo === 'plano' ? legacyPlanStatus(body) : 'final';
|
|
260
264
|
const data = {
|
|
261
265
|
schema_versao: SCHEMA_VERSION,
|
|
262
266
|
tipo: move.tipo,
|
|
@@ -366,18 +370,83 @@ export function applyMigration(root, plan) {
|
|
|
366
370
|
for (const dir of SOURCE_DIRS)
|
|
367
371
|
removeLegacyDir(root, `docs/${dir}`, git);
|
|
368
372
|
}
|
|
369
|
-
const
|
|
373
|
+
const FRONTMATTER = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
374
|
+
/**
|
|
375
|
+
* Planos migrados antes da 2.3.0 ficaram `em-execucao` quando tinham caixa aberta. Os que não declaram `arquivos`
|
|
376
|
+
* ganham o status que a migração daria hoje; quem preencheu `arquivos` está trabalhando neles e fica como está.
|
|
377
|
+
*/
|
|
378
|
+
export function planReclassification(root) {
|
|
379
|
+
const dir = join(root, 'docs', 'trabalhos');
|
|
380
|
+
if (!existsSync(dir))
|
|
381
|
+
return [];
|
|
382
|
+
const found = [];
|
|
383
|
+
for (const id of readdirSync(dir).sort()) {
|
|
384
|
+
const file = join(dir, id, 'PLAN.md');
|
|
385
|
+
if (!existsSync(file))
|
|
386
|
+
continue;
|
|
387
|
+
const content = readFileSync(file, 'utf8');
|
|
388
|
+
const match = FRONTMATTER.exec(content);
|
|
389
|
+
if (!match)
|
|
390
|
+
continue;
|
|
391
|
+
let data;
|
|
392
|
+
try {
|
|
393
|
+
data = parse(match[1] ?? '');
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (typeof data !== 'object' || data === null)
|
|
399
|
+
continue;
|
|
400
|
+
const { migrado, status, arquivos } = data;
|
|
401
|
+
if (migrado !== true || status !== 'em-execucao' || (Array.isArray(arquivos) && arquivos.length))
|
|
402
|
+
continue;
|
|
403
|
+
found.push({ id, de: status, para: legacyPlanStatus(content.slice(match[0].length)) });
|
|
404
|
+
}
|
|
405
|
+
return found;
|
|
406
|
+
}
|
|
407
|
+
/** Troca só a linha `status:` do frontmatter, para não reformatar o resto do arquivo. */
|
|
408
|
+
export function applyReclassification(root, changes) {
|
|
409
|
+
for (const change of changes) {
|
|
410
|
+
const file = join(root, 'docs', 'trabalhos', change.id, 'PLAN.md');
|
|
411
|
+
const content = readFileSync(file, 'utf8');
|
|
412
|
+
const match = FRONTMATTER.exec(content);
|
|
413
|
+
if (!match)
|
|
414
|
+
continue;
|
|
415
|
+
const frontmatter = match[0].replace(/^status:[ \t]*['"]?em-execucao['"]?[ \t]*$/m, `status: ${change.para}`);
|
|
416
|
+
writeFileSync(file, frontmatter + content.slice(match[0].length));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function runReclassification(root, yes) {
|
|
420
|
+
const changes = planReclassification(root);
|
|
421
|
+
if (!changes.length) {
|
|
422
|
+
console.log('Nenhum plano migrado sem `arquivos` está como em-execucao: nada a reclassificar.');
|
|
423
|
+
return 0;
|
|
424
|
+
}
|
|
425
|
+
console.log([`Planos migrados a reclassificar (${changes.length}):`, ...changes.map((change) => ` ${change.id}: ${change.de} → ${change.para}`)].join('\n'));
|
|
426
|
+
if (!yes) {
|
|
427
|
+
console.log('\nSimulação — nada foi alterado. Rode de novo com --yes para reclassificar.');
|
|
428
|
+
return 0;
|
|
429
|
+
}
|
|
430
|
+
applyReclassification(root, changes);
|
|
431
|
+
console.log(`\nReclassificados: ${changes.length} plano(s).`);
|
|
432
|
+
return 0;
|
|
433
|
+
}
|
|
434
|
+
const MIGRATION_FLAGS = ['--yes', '-y', '--sim', '--reclassificar'];
|
|
370
435
|
const REFERENCES_SHOWN = 40;
|
|
371
|
-
/** `ai-harness migrar [caminho] [--yes]`; sem `--yes` só simula. */
|
|
436
|
+
/** `ai-harness migrar [caminho] [--reclassificar] [--yes]`; sem `--yes` só simula. */
|
|
372
437
|
export function runMigrationCommand(argv) {
|
|
373
438
|
let path;
|
|
374
439
|
let yes = false;
|
|
440
|
+
let reclassify = false;
|
|
375
441
|
for (const token of argv) {
|
|
376
442
|
if (!token.startsWith('-')) {
|
|
377
443
|
if (path !== undefined)
|
|
378
444
|
throw new Error(`Argumento inesperado: ${token}`);
|
|
379
445
|
path = token;
|
|
380
446
|
}
|
|
447
|
+
else if (token === '--reclassificar') {
|
|
448
|
+
reclassify = true;
|
|
449
|
+
}
|
|
381
450
|
else if (MIGRATION_FLAGS.includes(token)) {
|
|
382
451
|
yes = true;
|
|
383
452
|
}
|
|
@@ -389,6 +458,8 @@ export function runMigrationCommand(argv) {
|
|
|
389
458
|
const root = resolve(process.cwd(), path ?? '.');
|
|
390
459
|
if (!existsSync(root))
|
|
391
460
|
throw new Error(`Pasta não encontrada: ${root}`);
|
|
461
|
+
if (reclassify)
|
|
462
|
+
return runReclassification(root, yes);
|
|
392
463
|
const plan = planMigration(root);
|
|
393
464
|
if (!plan.moves.length && !plan.conflicts.length) {
|
|
394
465
|
const ignored = plan.ignored.length ? `\nIgnorados: ${plan.ignored.join(', ')}` : '';
|
|
@@ -20,7 +20,7 @@ function readPlan(root, workId) {
|
|
|
20
20
|
return null;
|
|
21
21
|
const listed = Array.isArray(data.arquivos) ? data.arquivos : [];
|
|
22
22
|
const arquivos = listed.filter((entry) => typeof entry === 'string').map(normalize).filter(Boolean);
|
|
23
|
-
return { status: String(data.status ?? ''), arquivos };
|
|
23
|
+
return { status: String(data.status ?? ''), arquivos, migrado: data.migrado === true };
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
26
|
* Trabalhos em execução (PLAN.md `em-execucao` ou listados no estado.json) que declaram arquivos em comum
|
|
@@ -40,7 +40,9 @@ export async function findConflicts(root, workId) {
|
|
|
40
40
|
if (!plan || FINISHED.has(plan.status) || (plan.status !== 'em-execucao' && !listed.has(id)))
|
|
41
41
|
continue;
|
|
42
42
|
if (!plan.arquivos.length) {
|
|
43
|
-
|
|
43
|
+
// Plano migrado do layout antigo não diz o que tocou; só avisa quando alguém o está executando de fato.
|
|
44
|
+
if (!plan.migrado || listed.has(id))
|
|
45
|
+
report.semArquivos.push(id);
|
|
44
46
|
continue;
|
|
45
47
|
}
|
|
46
48
|
const shared = own.arquivos.filter((a) => plan.arquivos.some((b) => overlaps(a, b)));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fontdata/ai-harness",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"description": "Harness de engenharia com IA da Fontdata para Claude Code: roda os slash commands direto na pasta do projeto via npx, sem instalar nada — ou instala-os em .claude/commands.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs';
|
|
3
|
-
import { extname, relative, join, dirname } from 'node:path';
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, openSync, closeSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import { extname, relative, join, dirname, sep } from 'node:path';
|
|
4
4
|
|
|
5
5
|
const MAX_BLOCO = Number(process.env.COMENTARIOS_MAX_BLOCO ?? 3);
|
|
6
6
|
const MAX_DOCSTRING = Number(process.env.COMENTARIOS_MAX_DOCSTRING ?? 5);
|
|
@@ -9,7 +9,8 @@ const DENSIDADE_ALVO = 0.1;
|
|
|
9
9
|
const EXTENSOES = new Set([
|
|
10
10
|
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
11
11
|
'.py', '.go', '.rs', '.java', '.cs', '.php',
|
|
12
|
-
'.pas', '.dpr', '.sql', '.sh',
|
|
12
|
+
'.pas', '.dpr', '.dpk', '.lpr', '.sql', '.sh', '.bash', '.zsh',
|
|
13
|
+
'.ps1', '.psm1', '.bat', '.cmd',
|
|
13
14
|
]);
|
|
14
15
|
|
|
15
16
|
const IGNORAR = [
|
|
@@ -21,14 +22,18 @@ const IGNORAR = [
|
|
|
21
22
|
];
|
|
22
23
|
|
|
23
24
|
const CABECALHO_PERMITIDO =
|
|
24
|
-
/eslint|ts-nocheck|ts-ignore|prettier|@license|copyright|spdx|jshint|globals
|
|
25
|
+
/eslint|ts-nocheck|ts-check|ts-ignore|prettier|@license|copyright|spdx|jshint|globals|use (strict|client|server)|code generated|do not edit|auto-generated/i;
|
|
25
26
|
|
|
26
27
|
const BANNER = /^[-=─-╿_*#~+]{3,}|[-=─-╿_*#~+]{3,}$/;
|
|
27
28
|
|
|
29
|
+
// Só a palavra ignora caixa: com a flag `i`, o romano casaria o "i" de "specialty".
|
|
30
|
+
const semCaixa = (texto) => texto.replace(/\p{L}/gu, (c) => `[${c.toLowerCase()}${c.toUpperCase()}]`);
|
|
31
|
+
const PROCESSO = new RegExp(`\\b(${semCaixa('task|tarefa|fase|etapa|plano|spec|sprint|epico|épico')})\\s*(\\d|[IVX]+\\b)`, 'u');
|
|
32
|
+
|
|
28
33
|
const RASTREIO = [
|
|
29
|
-
[
|
|
34
|
+
[PROCESSO, 'referência a task/fase/plano/spec'],
|
|
30
35
|
[/\bT\d+(\.\d+)+\b/, 'referência a task (T0.0)'],
|
|
31
|
-
[/docs\/(plans|specs|bugs|investigacoes)\//i, 'caminho de docs/ dentro do código'],
|
|
36
|
+
[/docs\/(plans|specs|bugs|investigacoes|trabalhos)\//i, 'caminho de docs/ dentro do código'],
|
|
32
37
|
[/\bachado d[oe]\b/i, 'achado de review no código'],
|
|
33
38
|
[/@[\w-]*(reviewer|auditor|orquestrador|agent)\b/i, 'menção a agent no código'],
|
|
34
39
|
[/\b(commit|sha|hash)\s+[0-9a-f]{7,40}\b/i, 'hash de commit'],
|
|
@@ -38,33 +43,136 @@ const DATA = /(^|[^"'\w])20\d{2}-\d{2}-\d{2}([^"'\w]|$)/;
|
|
|
38
43
|
const CODIGO_TOKEN = /(^|\s)(const|let|var|function|return|if|for|while|await|import|export|def|class)\b/;
|
|
39
44
|
const CODIGO_FIM = /[;{}]\s*$/;
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
const so = (...exts) => new Set(exts);
|
|
47
|
+
const JS = so('.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs');
|
|
48
|
+
const C_LIKE = so(...JS, '.go', '.rs', '.java', '.cs', '.php');
|
|
49
|
+
const PASCAL = so('.pas', '.dpr', '.dpk', '.lpr');
|
|
50
|
+
const SHELL = so('.sh', '.bash', '.zsh');
|
|
51
|
+
const POWERSHELL = so('.ps1', '.psm1');
|
|
52
|
+
const BATCH = so('.bat', '.cmd');
|
|
53
|
+
|
|
54
|
+
const SINTAXE = [
|
|
55
|
+
[so('.php'), { linha: ['//', '#'], blocos: [['/*', '*/']], naoComentario: /^#\[/ }],
|
|
56
|
+
[C_LIKE, { linha: ['//'], blocos: [['/*', '*/']] }],
|
|
57
|
+
[PASCAL, { linha: ['//'], blocos: [['{', '}'], ['(*', '*)']] }],
|
|
58
|
+
[so('.sql'), { linha: ['--'], blocos: [['/*', '*/']] }],
|
|
59
|
+
[POWERSHELL, { linha: ['#'], blocos: [['<#', '#>']] }],
|
|
60
|
+
[so('.py', ...SHELL), { linha: ['#'], blocos: [] }],
|
|
61
|
+
[BATCH, { linha: ['::'], blocos: [] }],
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// Têm forma de comentário, mas são lidas pelo compilador, pelo interpretador ou por uma ferramenta.
|
|
65
|
+
const DIRETIVA = Symbol('diretiva');
|
|
66
|
+
const DIRETIVAS = [
|
|
67
|
+
[PASCAL, /^(\{|\(\*)[$%]/],
|
|
68
|
+
[C_LIKE, /^\/[/*]\s*(#?region\b|#?endregion\b|<\/?editor-fold|@formatter:)/i],
|
|
69
|
+
[JS, /^\/\/\/\s*<(reference|amd-module|amd-dependency)\b/],
|
|
70
|
+
[JS, /^\/[/*]\*?\s*(@ts-(check|nocheck|ignore|expect-error)\b|eslint|jshint|jscs:|tslint:|prettier-ignore|(istanbul|c8|v8) ignore|biome-ignore|deno-|oxlint-|@flow\b|@jsx|@vite-ignore|@vitest-environment|@jest-environment|webpack[A-Z]\w*:|@refresh\b)/i],
|
|
71
|
+
[JS, /^\/\*+\s*globals?\s/],
|
|
72
|
+
[JS, /^\/\/[#@]\s*source(Mapping)?URL=/],
|
|
73
|
+
[so('.go'), /^\/\/(go:|line |export |extern )|^\/\/\s*(\+build\b|nolint\b|lint:ignore\b)/],
|
|
74
|
+
[so('.rs'), /^#!\[/],
|
|
75
|
+
[so('.java'), /^\/[/*]\s*(noinspection\b|NOSONAR\b|CHECKSTYLE[:.]|\$NON-NLS)/],
|
|
76
|
+
[so('.cs'), /^#\s*(region|endregion|if|elif|else|endif|define|undef|pragma|nullable|warning|error|line)\b/],
|
|
77
|
+
[so('.cs'), /^\/\/\s*(ReSharper\s|<\/?auto-generated)/i],
|
|
78
|
+
[so('.php'), /^\/[/*]\*?\s*(phpcs:|@phpstan-|@psalm-|@codeCoverageIgnore|@noinspection\b|@var\s)/],
|
|
79
|
+
[so('.sql'), /^\/\*[!+]/],
|
|
80
|
+
[so('.sql'), /^--\s*(liquibase\s|changeset\s|rollback\b|precondition|comment:|validCheckSum\b|ignoreLines:|\+(goose|migrate)\b|name:\s*\S+\s*:|noqa\b|sqlfluff:|#?(region|endregion)\b)/i],
|
|
81
|
+
[so('.py'), /^#\s*(-\*-.*-\*-|(en)?coding[:=]|vim?:|noqa\b|nosec\b|type:|pylint:|mypy:|pyright:|pyre-|pytype:|ruff:|isort:|fmt:|pragma\b)/i],
|
|
82
|
+
[SHELL, /^#\s*(shellcheck\s|-\*-.*-\*-|vim?:|ex:)|^#(compdef|autoload)\b/i],
|
|
83
|
+
[POWERSHELL, /^#(requires|region|endregion)\b/i],
|
|
84
|
+
];
|
|
85
|
+
const BLOCOS_DIRETIVA = [
|
|
86
|
+
[SHELL, /^###\s*BEGIN INIT INFO/, /^###\s*END INIT INFO/],
|
|
87
|
+
[POWERSHELL, /^#\s*SIG\s*#\s*Begin signature block/i, /^#\s*SIG\s*#\s*End signature block/i],
|
|
88
|
+
];
|
|
89
|
+
const HEREDOC = /(?<!<)<<(?!<)-?\s*(['"]?)([A-Za-z_]\w*)\1/g;
|
|
90
|
+
|
|
91
|
+
const limpar = (texto) => texto.replace(/^\*+/, '').trim();
|
|
92
|
+
|
|
93
|
+
function comentarioDeLinha(t, sintaxe, ext) {
|
|
94
|
+
if (BATCH.has(ext)) {
|
|
95
|
+
if (t.startsWith('::')) return t.replace(/^:+/, '').trim();
|
|
96
|
+
const rem = t.match(/^@?rem(?:[\s.:/\\](.*))?$/i);
|
|
97
|
+
return rem ? (rem[1] ?? '').trim() : null;
|
|
98
|
+
}
|
|
99
|
+
const marcador = sintaxe.linha.find((m) => t.startsWith(m));
|
|
100
|
+
if (!marcador || sintaxe.naoComentario?.test(t)) return null;
|
|
101
|
+
let k = 0;
|
|
102
|
+
while (t[k] === marcador[0]) k++;
|
|
103
|
+
return t.slice(k).trim();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Devolve, por linha: o texto do comentário, DIRETIVA, ou null para código e linha em branco.
|
|
107
|
+
function classificar(linhas, ext) {
|
|
108
|
+
const sintaxe = SINTAXE.find(([exts]) => exts.has(ext))?.[1] ?? { linha: ['//'], blocos: [['/*', '*/']] };
|
|
109
|
+
const saida = [];
|
|
110
|
+
const heredocs = [];
|
|
111
|
+
let fimTexto = null;
|
|
112
|
+
let fimDiretiva = null;
|
|
113
|
+
let bloco = null;
|
|
114
|
+
for (let i = 0; i < linhas.length; i++) {
|
|
115
|
+
const t = linhas[i].trim();
|
|
116
|
+
if (heredocs.length) {
|
|
117
|
+
if (t === heredocs[0]) heredocs.shift();
|
|
118
|
+
saida.push(null);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (fimTexto) {
|
|
122
|
+
if (fimTexto.test(t)) fimTexto = null;
|
|
123
|
+
saida.push(null);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (bloco) {
|
|
127
|
+
const pos = t.indexOf(bloco.fim);
|
|
128
|
+
saida.push(bloco.diretiva ? DIRETIVA : limpar(pos === -1 ? t : t.slice(0, pos)));
|
|
129
|
+
if (pos !== -1) bloco = null;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!t) { saida.push(null); continue; }
|
|
133
|
+
if (fimDiretiva) {
|
|
134
|
+
if (fimDiretiva.test(t)) fimDiretiva = null;
|
|
135
|
+
saida.push(DIRETIVA);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const linhaDiretiva = BLOCOS_DIRETIVA.find(([exts, abre]) => exts.has(ext) && abre.test(t));
|
|
139
|
+
if (linhaDiretiva) { fimDiretiva = linhaDiretiva[2]; saida.push(DIRETIVA); continue; }
|
|
140
|
+
|
|
141
|
+
const diretiva = (i === 0 && t.startsWith('#!')) || DIRETIVAS.some(([exts, re]) => exts.has(ext) && re.test(t));
|
|
142
|
+
const abre = sintaxe.blocos.find(([inicio]) => t.startsWith(inicio));
|
|
143
|
+
if (abre) {
|
|
144
|
+
const resto = t.slice(abre[0].length);
|
|
145
|
+
const pos = resto.indexOf(abre[1]);
|
|
146
|
+
if (pos === -1) bloco = { fim: abre[1], diretiva };
|
|
147
|
+
saida.push(diretiva ? DIRETIVA : limpar(pos === -1 ? resto : resto.slice(0, pos)));
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (diretiva) { saida.push(DIRETIVA); continue; }
|
|
151
|
+
|
|
152
|
+
const c = comentarioDeLinha(t, sintaxe, ext);
|
|
153
|
+
saida.push(c);
|
|
154
|
+
if (c !== null) continue;
|
|
155
|
+
if (SHELL.has(ext)) for (const m of t.matchAll(HEREDOC)) heredocs.push(m[2]);
|
|
156
|
+
if (POWERSHELL.has(ext) && /@["']$/.test(t)) fimTexto = t.endsWith('"') ? /^"@/ : /^'@/;
|
|
49
157
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
158
|
+
// O comentário colado em `import "C"` é o preâmbulo do cgo: código C, não comentário.
|
|
159
|
+
if (ext === '.go') {
|
|
160
|
+
for (let i = 0; i < linhas.length; i++) {
|
|
161
|
+
if (linhas[i].trim() !== 'import "C"') continue;
|
|
162
|
+
for (let j = i - 1; j >= 0 && typeof saida[j] === 'string'; j--) saida[j] = DIRETIVA;
|
|
163
|
+
}
|
|
55
164
|
}
|
|
56
|
-
|
|
57
|
-
if (t.startsWith('/*')) return t.replace(/^\/\*+/, '').trim();
|
|
58
|
-
if (t.startsWith('*')) return t.replace(/^\*+\/?/, '').trim();
|
|
59
|
-
return null;
|
|
165
|
+
return saida;
|
|
60
166
|
}
|
|
61
167
|
|
|
168
|
+
const ehTexto = (c) => typeof c === 'string';
|
|
169
|
+
|
|
62
170
|
function analisar(caminho, fonte) {
|
|
63
171
|
const ext = extname(caminho);
|
|
64
172
|
const linhas = fonte.split('\n');
|
|
65
173
|
const achados = [];
|
|
66
174
|
const marca = (linha, msg) => achados.push({ linha, msg });
|
|
67
|
-
const ehComentario = linhas
|
|
175
|
+
const ehComentario = classificar(linhas, ext);
|
|
68
176
|
|
|
69
177
|
let primeiroCodigo = -1;
|
|
70
178
|
for (let i = 0; i < linhas.length; i++) {
|
|
@@ -73,10 +181,10 @@ function analisar(caminho, fonte) {
|
|
|
73
181
|
|
|
74
182
|
let i = 0;
|
|
75
183
|
while (i < linhas.length) {
|
|
76
|
-
if (ehComentario[i]
|
|
184
|
+
if (!ehTexto(ehComentario[i])) { i++; continue; }
|
|
77
185
|
const inicio = i;
|
|
78
186
|
const conteudo = [];
|
|
79
|
-
while (i < linhas.length && ehComentario[i]
|
|
187
|
+
while (i < linhas.length && ehTexto(ehComentario[i])) { conteudo.push(ehComentario[i]); i++; }
|
|
80
188
|
const fim = i - 1;
|
|
81
189
|
const corpo = conteudo.filter(Boolean);
|
|
82
190
|
const texto = corpo.join(' ');
|
|
@@ -99,9 +207,9 @@ function analisar(caminho, fonte) {
|
|
|
99
207
|
}
|
|
100
208
|
|
|
101
209
|
let proxima = fim + 1;
|
|
102
|
-
while (proxima < linhas.length && !linhas[proxima].trim()) proxima++;
|
|
210
|
+
while (proxima < linhas.length && (!linhas[proxima].trim() || ehComentario[proxima] === DIRETIVA)) proxima++;
|
|
103
211
|
const ehDocstring =
|
|
104
|
-
/^(\/\*\*|"""|'''
|
|
212
|
+
/^(\/\*\*|"""|'''|\/\/\/|<#)/.test(linhas[inicio].trim()) &&
|
|
105
213
|
proxima < linhas.length &&
|
|
106
214
|
ehComentario[proxima] === null;
|
|
107
215
|
const teto = ehDocstring ? MAX_DOCSTRING : MAX_BLOCO;
|
|
@@ -116,17 +224,52 @@ function analisar(caminho, fonte) {
|
|
|
116
224
|
}
|
|
117
225
|
}
|
|
118
226
|
|
|
119
|
-
const comentadas = ehComentario.filter(
|
|
227
|
+
const comentadas = ehComentario.filter(ehTexto).length;
|
|
120
228
|
const uteis = linhas.filter((l) => l.trim()).length;
|
|
121
229
|
return { achados, comentadas, uteis, densidade: uteis ? comentadas / uteis : 0 };
|
|
122
230
|
}
|
|
123
231
|
|
|
232
|
+
const raiz = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
233
|
+
const BASELINE = join(raiz, '.claude', 'hooks', 'comentarios', 'baseline.json');
|
|
234
|
+
|
|
235
|
+
// Pasta oculta na raiz (.claude, .ai-harness, .vscode...) é de ferramenta, não do projeto.
|
|
236
|
+
function emPastaOculta(caminho) {
|
|
237
|
+
const rel = relative(raiz, caminho);
|
|
238
|
+
if (!rel || rel.startsWith('..')) return false;
|
|
239
|
+
return rel.split(sep).slice(0, -1).some((parte) => parte.startsWith('.'));
|
|
240
|
+
}
|
|
241
|
+
|
|
124
242
|
function elegivel(caminho) {
|
|
125
|
-
return EXTENSOES.has(extname(caminho)) && !IGNORAR.some((re) => re.test(caminho));
|
|
243
|
+
return EXTENSOES.has(extname(caminho)) && !IGNORAR.some((re) => re.test(caminho)) && !emPastaOculta(caminho);
|
|
126
244
|
}
|
|
127
245
|
|
|
128
|
-
|
|
129
|
-
const
|
|
246
|
+
function gravarAtomico(arquivo, conteudo) {
|
|
247
|
+
const temp = `${arquivo}.${process.pid}.tmp`;
|
|
248
|
+
writeFileSync(temp, conteudo);
|
|
249
|
+
renameSync(temp, arquivo);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const esperar = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
253
|
+
|
|
254
|
+
// Vários agents editam em paralelo: a trava serializa o reler-e-gravar, e cada um mexe só na própria chave.
|
|
255
|
+
function atualizarBaseline(chave, valor) {
|
|
256
|
+
const trava = `${BASELINE}.lock`;
|
|
257
|
+
let travado = false;
|
|
258
|
+
for (let tentativa = 0; tentativa < 300 && !travado; tentativa++) {
|
|
259
|
+
try { closeSync(openSync(trava, 'wx')); travado = true; } catch {
|
|
260
|
+
try { if (Date.now() - statSync(trava).mtimeMs > 10_000) rmSync(trava, { force: true }); } catch {}
|
|
261
|
+
if (!travado) esperar(10);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const atual = JSON.parse(readFileSync(BASELINE, 'utf8'));
|
|
266
|
+
if (valor === 0) delete atual[chave];
|
|
267
|
+
else atual[chave] = Math.min(valor, atual[chave] ?? valor);
|
|
268
|
+
gravarAtomico(BASELINE, `${JSON.stringify(atual, null, 2)}\n`);
|
|
269
|
+
} catch {} finally {
|
|
270
|
+
if (travado) rmSync(trava, { force: true });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
130
273
|
|
|
131
274
|
function varrer(dir, saida = []) {
|
|
132
275
|
let itens;
|
|
@@ -134,7 +277,7 @@ function varrer(dir, saida = []) {
|
|
|
134
277
|
for (const item of itens) {
|
|
135
278
|
const cheio = join(dir, item.name);
|
|
136
279
|
if (item.isDirectory()) {
|
|
137
|
-
if (item.name === 'node_modules' || item.name
|
|
280
|
+
if (item.name === 'node_modules' || item.name.startsWith('.')) continue;
|
|
138
281
|
if (IGNORAR.some((re) => re.test(`${cheio}/`))) continue;
|
|
139
282
|
varrer(cheio, saida);
|
|
140
283
|
} else if (elegivel(cheio)) saida.push(cheio);
|
|
@@ -153,7 +296,7 @@ if (process.argv.includes('--gerar-baseline')) {
|
|
|
153
296
|
if (achados.length > 0) { mapa[relative(raiz, f)] = achados.length; arquivos++; violacoes += achados.length; }
|
|
154
297
|
}
|
|
155
298
|
if (!existsSync(dirname(BASELINE))) mkdirSync(dirname(BASELINE), { recursive: true });
|
|
156
|
-
|
|
299
|
+
gravarAtomico(BASELINE, `${JSON.stringify(mapa, null, 2)}\n`);
|
|
157
300
|
console.log(`[comentarios] baseline gravado: ${arquivos} arquivos legados, ${violacoes} violações toleradas.`);
|
|
158
301
|
console.log('[comentarios] arquivo novo responde ao padrão cheio; legado só não pode piorar.');
|
|
159
302
|
process.exit(0);
|
|
@@ -179,9 +322,7 @@ const tolerado = baseline?.[chave] ?? 0;
|
|
|
179
322
|
|
|
180
323
|
if (achados.length <= tolerado) {
|
|
181
324
|
if (baseline && tolerado > 0 && achados.length < tolerado) {
|
|
182
|
-
|
|
183
|
-
else baseline[chave] = achados.length;
|
|
184
|
-
try { writeFileSync(BASELINE, `${JSON.stringify(baseline, null, 2)}\n`); } catch {}
|
|
325
|
+
atualizarBaseline(chave, achados.length);
|
|
185
326
|
console.error(`[comentarios] ${rel}: ${tolerado} → ${achados.length} violações. Catraca desceu — não pode mais subir.`);
|
|
186
327
|
} else if (achados.length === 0 && densidade > DENSIDADE_ALVO) {
|
|
187
328
|
console.error(`[comentarios] aviso: ${rel} tem ${(densidade * 100).toFixed(0)}% de linhas de comentário (${comentadas}/${uteis}), alvo <= ${DENSIDADE_ALVO * 100}%.`);
|
|
@@ -61,7 +61,7 @@ export function buildEvent(fields) {
|
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
63
|
* Trabalhos ativos: os `trabalhos_ativos` do .ai-harness/estado.json (ou o `trabalho_ativo` do formato
|
|
64
|
-
* anterior) ou, sem eles, os PLAN.md com `status: em-execucao
|
|
64
|
+
* anterior) ou, sem eles, os PLAN.md com `status: em-execucao` (menos os migrados sem `arquivos`). Várias sessões podem executar
|
|
65
65
|
* trabalhos diferentes na mesma pasta, então a lista pode ter mais de um id.
|
|
66
66
|
* @param {string} root
|
|
67
67
|
* @returns {string[]}
|
|
@@ -88,7 +88,10 @@ export function findActiveWorks(root) {
|
|
|
88
88
|
if (!existsSync(plan)) return false;
|
|
89
89
|
const frontmatter = /^\s*---\r?\n([\s\S]*?)\r?\n---/.exec(readFileSync(plan, 'utf8'))?.[1] ?? '';
|
|
90
90
|
// Só o status do plano, na coluna zero; os status das tasks ficam indentados.
|
|
91
|
-
|
|
91
|
+
if (!/^status:\s*['"]?em-execucao['"]?\s*$/m.test(frontmatter)) return false;
|
|
92
|
+
// Plano migrado do layout antigo sem `arquivos` ficou em-execucao pela migração, não por alguém executá-lo.
|
|
93
|
+
const migradoSemArquivos = /^migrado:\s*true\s*$/m.test(frontmatter) && !/^arquivos:[ \t]*(\[[ \t]*[^\]\s]|\r?\n[ \t]*-\s)/m.test(frontmatter);
|
|
94
|
+
return !migradoSemArquivos;
|
|
92
95
|
});
|
|
93
96
|
}
|
|
94
97
|
|