ai-dev-workflow-cli 0.0.0-stage → 0.5.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/.continue/prompts/analisar-projeto.md +7 -0
- package/.continue/prompts/documentar-task.md +7 -0
- package/.continue/prompts/planejar-task.md +9 -0
- package/.continue/prompts/revisar-codigo.md +7 -0
- package/.continue/rules/workflow.md +12 -0
- package/.github/pull_request_template.md +10 -0
- package/.gitignore +28 -0
- package/AGENTS.md +38 -0
- package/CLAUDE.md +7 -0
- package/GEMINI.md +7 -0
- package/LICENSE +21 -0
- package/README.md +82 -2
- package/agents/architect.md +5 -0
- package/agents/developer.md +5 -0
- package/agents/discovery.md +5 -0
- package/agents/documentation.md +5 -0
- package/agents/environment.md +3 -0
- package/agents/reviewer.md +5 -0
- package/bin/ai-dev-workflow.js +10 -0
- package/catalog/central-v0.2.json +197 -0
- package/catalog/legacy-v1.json +158 -0
- package/catalog/template.json +207 -0
- package/docs/CLI.md +216 -0
- package/docs/DISCOVERY_CLI.md +83 -0
- package/docs/LAYOUT.md +13 -0
- package/docs/MULTI_AI.md +36 -0
- package/docs/PERSISTENCE.md +80 -0
- package/docs/QUICKSTART.md +203 -0
- package/docs/WORKFLOW.md +58 -0
- package/integrations/codex/README.md +14 -0
- package/integrations/continue/README.md +19 -0
- package/package.json +45 -4
- package/payload/central/.ai-dev-workflow/agents/architect.md +5 -0
- package/payload/central/.ai-dev-workflow/agents/developer.md +5 -0
- package/payload/central/.ai-dev-workflow/agents/discovery.md +5 -0
- package/payload/central/.ai-dev-workflow/agents/documentation.md +5 -0
- package/payload/central/.ai-dev-workflow/agents/environment.md +3 -0
- package/payload/central/.ai-dev-workflow/agents/reviewer.md +5 -0
- package/payload/central/.ai-dev-workflow/docs/WORKFLOW.md +58 -0
- package/payload/central/.ai-dev-workflow/integrations/codex/README.md +14 -0
- package/payload/central/.ai-dev-workflow/integrations/continue/README.md +19 -0
- package/payload/central/.ai-dev-workflow/rules/cost-control.md +6 -0
- package/payload/central/.ai-dev-workflow/rules/database.md +46 -0
- package/payload/central/.ai-dev-workflow/rules/general.md +7 -0
- package/payload/central/.ai-dev-workflow/rules/git-workflow.md +16 -0
- package/payload/central/.ai-dev-workflow/rules/security.md +11 -0
- package/payload/central/.ai-dev-workflow/rules/testing.md +6 -0
- package/payload/central/.ai-dev-workflow/stacks/nodejs.md +3 -0
- package/payload/central/.ai-dev-workflow/stacks/php-laravel.md +6 -0
- package/payload/central/.ai-dev-workflow/stacks/php-native.md +3 -0
- package/payload/central/.ai-dev-workflow/stacks/wordpress.md +4 -0
- package/payload/central/.ai-dev-workflow/templates/ARCHITECTURE.md +8 -0
- package/payload/central/.ai-dev-workflow/templates/BASELINE.md +16 -0
- package/payload/central/.ai-dev-workflow/templates/CURRENT_STATE.md +15 -0
- package/payload/central/.ai-dev-workflow/templates/DATABASE_CHANGE.md +31 -0
- package/payload/central/.ai-dev-workflow/templates/DECISIONS.md +9 -0
- package/payload/central/.ai-dev-workflow/templates/DISCOVERY.md +13 -0
- package/payload/central/.ai-dev-workflow/templates/ENVIRONMENT.md +20 -0
- package/payload/central/.ai-dev-workflow/templates/PR_CHECKLIST.md +9 -0
- package/payload/central/.ai-dev-workflow/templates/REQUIREMENTS.md +9 -0
- package/payload/central/.ai-dev-workflow/templates/TASK.md +26 -0
- package/payload/central/.ai-dev-workflow/templates/TASK_REPORT.md +20 -0
- package/payload/central/.continue/prompts/analisar-projeto.md +7 -0
- package/payload/central/.continue/prompts/documentar-task.md +7 -0
- package/payload/central/.continue/prompts/planejar-task.md +9 -0
- package/payload/central/.continue/prompts/revisar-codigo.md +7 -0
- package/payload/central/.continue/rules/workflow.md +12 -0
- package/payload/central/.github/pull_request_template.md +10 -0
- package/payload/central/.gitignore.txt +28 -0
- package/payload/central/AGENTS.md +38 -0
- package/payload/central/CLAUDE.md +7 -0
- package/payload/central/GEMINI.md +7 -0
- package/payload/central-v0.2/.ai-dev-workflow/agents/architect.md +5 -0
- package/payload/central-v0.2/.ai-dev-workflow/agents/developer.md +5 -0
- package/payload/central-v0.2/.ai-dev-workflow/agents/discovery.md +5 -0
- package/payload/central-v0.2/.ai-dev-workflow/agents/documentation.md +5 -0
- package/payload/central-v0.2/.ai-dev-workflow/agents/environment.md +3 -0
- package/payload/central-v0.2/.ai-dev-workflow/agents/reviewer.md +5 -0
- package/payload/central-v0.2/.ai-dev-workflow/docs/WORKFLOW.md +58 -0
- package/payload/central-v0.2/.ai-dev-workflow/integrations/codex/README.md +14 -0
- package/payload/central-v0.2/.ai-dev-workflow/integrations/continue/README.md +19 -0
- package/payload/central-v0.2/.ai-dev-workflow/rules/cost-control.md +6 -0
- package/payload/central-v0.2/.ai-dev-workflow/rules/database.md +46 -0
- package/payload/central-v0.2/.ai-dev-workflow/rules/general.md +7 -0
- package/payload/central-v0.2/.ai-dev-workflow/rules/git-workflow.md +16 -0
- package/payload/central-v0.2/.ai-dev-workflow/rules/security.md +11 -0
- package/payload/central-v0.2/.ai-dev-workflow/rules/testing.md +6 -0
- package/payload/central-v0.2/.ai-dev-workflow/stacks/nodejs.md +3 -0
- package/payload/central-v0.2/.ai-dev-workflow/stacks/php-laravel.md +6 -0
- package/payload/central-v0.2/.ai-dev-workflow/stacks/php-native.md +3 -0
- package/payload/central-v0.2/.ai-dev-workflow/stacks/wordpress.md +4 -0
- package/payload/central-v0.2/.ai-dev-workflow/templates/ARCHITECTURE.md +8 -0
- package/payload/central-v0.2/.ai-dev-workflow/templates/BASELINE.md +16 -0
- package/payload/central-v0.2/.ai-dev-workflow/templates/CURRENT_STATE.md +15 -0
- package/payload/central-v0.2/.ai-dev-workflow/templates/DATABASE_CHANGE.md +31 -0
- package/payload/central-v0.2/.ai-dev-workflow/templates/DECISIONS.md +9 -0
- package/payload/central-v0.2/.ai-dev-workflow/templates/DISCOVERY.md +13 -0
- package/payload/central-v0.2/.ai-dev-workflow/templates/ENVIRONMENT.md +20 -0
- package/payload/central-v0.2/.ai-dev-workflow/templates/PR_CHECKLIST.md +9 -0
- package/payload/central-v0.2/.ai-dev-workflow/templates/REQUIREMENTS.md +9 -0
- package/payload/central-v0.2/.ai-dev-workflow/templates/TASK.md +26 -0
- package/payload/central-v0.2/.ai-dev-workflow/templates/TASK_REPORT.md +20 -0
- package/payload/central-v0.2/.continue/prompts/analisar-projeto.md +7 -0
- package/payload/central-v0.2/.continue/prompts/documentar-task.md +7 -0
- package/payload/central-v0.2/.continue/prompts/planejar-task.md +9 -0
- package/payload/central-v0.2/.continue/prompts/revisar-codigo.md +7 -0
- package/payload/central-v0.2/.continue/rules/workflow.md +12 -0
- package/payload/central-v0.2/.github/pull_request_template.md +10 -0
- package/payload/central-v0.2/.gitignore.txt +28 -0
- package/payload/central-v0.2/AGENTS.md +38 -0
- package/payload/legacy-v1/.continue/prompts/analisar-projeto.md +7 -0
- package/payload/legacy-v1/.continue/prompts/documentar-task.md +7 -0
- package/payload/legacy-v1/.continue/prompts/planejar-task.md +9 -0
- package/payload/legacy-v1/.continue/prompts/revisar-codigo.md +7 -0
- package/payload/legacy-v1/.continue/rules/workflow.md +12 -0
- package/payload/legacy-v1/.github/pull_request_template.md +10 -0
- package/payload/legacy-v1/.gitignore.txt +28 -0
- package/payload/legacy-v1/AGENTS.md +38 -0
- package/payload/legacy-v1/agents/architect.md +5 -0
- package/payload/legacy-v1/agents/developer.md +5 -0
- package/payload/legacy-v1/agents/discovery.md +5 -0
- package/payload/legacy-v1/agents/documentation.md +5 -0
- package/payload/legacy-v1/agents/environment.md +3 -0
- package/payload/legacy-v1/agents/reviewer.md +5 -0
- package/payload/legacy-v1/docs/WORKFLOW.md +58 -0
- package/payload/legacy-v1/integrations/codex/README.md +14 -0
- package/payload/legacy-v1/integrations/continue/README.md +19 -0
- package/payload/legacy-v1/rules/cost-control.md +6 -0
- package/payload/legacy-v1/rules/database.md +46 -0
- package/payload/legacy-v1/rules/general.md +7 -0
- package/payload/legacy-v1/rules/git-workflow.md +16 -0
- package/payload/legacy-v1/rules/security.md +11 -0
- package/payload/legacy-v1/rules/testing.md +6 -0
- package/payload/legacy-v1/stacks/nodejs.md +3 -0
- package/payload/legacy-v1/stacks/php-laravel.md +6 -0
- package/payload/legacy-v1/stacks/php-native.md +3 -0
- package/payload/legacy-v1/stacks/wordpress.md +4 -0
- package/payload/legacy-v1/templates/ARCHITECTURE.md +8 -0
- package/payload/legacy-v1/templates/BASELINE.md +16 -0
- package/payload/legacy-v1/templates/CURRENT_STATE.md +15 -0
- package/payload/legacy-v1/templates/DATABASE_CHANGE.md +31 -0
- package/payload/legacy-v1/templates/DECISIONS.md +9 -0
- package/payload/legacy-v1/templates/DISCOVERY.md +13 -0
- package/payload/legacy-v1/templates/ENVIRONMENT.md +20 -0
- package/payload/legacy-v1/templates/PR_CHECKLIST.md +9 -0
- package/payload/legacy-v1/templates/REQUIREMENTS.md +9 -0
- package/payload/legacy-v1/templates/TASK.md +26 -0
- package/payload/legacy-v1/templates/TASK_REPORT.md +20 -0
- package/rules/cost-control.md +6 -0
- package/rules/database.md +46 -0
- package/rules/general.md +7 -0
- package/rules/git-workflow.md +16 -0
- package/rules/security.md +11 -0
- package/rules/testing.md +6 -0
- package/scripts/check-catalog.js +11 -0
- package/src/cli.js +74 -0
- package/src/commands/discover.js +2 -0
- package/src/commands/doctor.js +1 -0
- package/src/commands/init.js +2 -0
- package/src/commands/status.js +1 -0
- package/src/commands/validate.js +1 -0
- package/src/core/adapters.js +9 -0
- package/src/core/catalog.js +52 -0
- package/src/core/diagnostics.js +82 -0
- package/src/core/errors.js +10 -0
- package/src/core/install-plan.js +49 -0
- package/src/core/installer.js +72 -0
- package/src/core/journal.js +28 -0
- package/src/core/manifest.js +55 -0
- package/src/discovery/detectors.js +156 -0
- package/src/discovery/evidence.js +12 -0
- package/src/discovery/reader.js +47 -0
- package/src/discovery/reconcile.js +18 -0
- package/src/discovery/report.js +24 -0
- package/src/discovery/scanner.js +60 -0
- package/src/output/json.js +1 -0
- package/src/output/text.js +18 -0
- package/src/persistence/history.js +23 -0
- package/src/persistence/markdown.js +25 -0
- package/src/persistence/record.js +90 -0
- package/src/persistence/storage.js +47 -0
- package/src/platform/filesystem.js +71 -0
- package/src/platform/git.js +123 -0
- package/stacks/nodejs.md +3 -0
- package/stacks/php-laravel.md +6 -0
- package/stacks/php-native.md +3 -0
- package/stacks/wordpress.md +4 -0
- package/templates/ARCHITECTURE.md +8 -0
- package/templates/BASELINE.md +16 -0
- package/templates/CURRENT_STATE.md +15 -0
- package/templates/DATABASE_CHANGE.md +31 -0
- package/templates/DECISIONS.md +9 -0
- package/templates/DISCOVERY.md +13 -0
- package/templates/ENVIRONMENT.md +20 -0
- package/templates/PR_CHECKLIST.md +9 -0
- package/templates/REQUIREMENTS.md +9 -0
- package/templates/TASK.md +26 -0
- package/templates/TASK_REPORT.md +20 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { hash, readKnown } from '../platform/filesystem.js';
|
|
2
|
+
import { fail } from './errors.js';
|
|
3
|
+
import { manifestPath, loadCatalog, contextEntries, semver, sha256 } from './catalog.js';
|
|
4
|
+
export async function readManifest(root, expected) {
|
|
5
|
+
const content = await readKnown(root, manifestPath);
|
|
6
|
+
if (!content) return null;
|
|
7
|
+
let m;
|
|
8
|
+
try { m = JSON.parse(content.toString('utf8')); }
|
|
9
|
+
catch { fail('INVALID_MANIFEST', 'Manifesto não contém JSON válido.'); }
|
|
10
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) fail('INVALID_MANIFEST', 'Manifesto deve ser objeto JSON.');
|
|
11
|
+
if (m.manifestVersion !== expected.manifestVersion) fail('UNSUPPORTED_MANIFEST', 'Formato do manifesto não suportado; nenhuma migração será executada.');
|
|
12
|
+
if (expected.layoutVersion === 'central-v2' && m.layoutVersion !== 'central-v2') fail('UNSUPPORTED_LAYOUT', 'Layout do manifesto não suportado.');
|
|
13
|
+
if (expected.layoutVersion === 'legacy-v1' && m.layoutVersion !== undefined) fail('UNSUPPORTED_LAYOUT', 'Layout legado ambíguo.');
|
|
14
|
+
if (!semver.test(m.templateVersion) || !semver.test(m.cliVersion) || !['new', 'existing'].includes(m.track) || !Array.isArray(m.files))
|
|
15
|
+
fail('INVALID_MANIFEST', 'Campos de manifesto inválidos.');
|
|
16
|
+
if (m.templateVersion !== expected.templateVersion)
|
|
17
|
+
fail('VERSION_PLAN_REQUIRED', 'Versão do template difere; upgrade/downgrade requer plano humano e não está implementado.');
|
|
18
|
+
if (m.track !== expected.track && expected.track) fail('TRACK_CONFLICT', 'Trilha instalada difere; alteração requer plano humano.');
|
|
19
|
+
const entries = new Map(expected.entries.map(e => [e.path, e]));
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
for (const f of m.files) {
|
|
22
|
+
if (!f || typeof f !== 'object') fail('INVALID_MANIFEST', 'Entrada de manifesto inválida.');
|
|
23
|
+
const entry = entries.get(f.path);
|
|
24
|
+
const legacyGitignore = expected.layoutVersion === 'legacy-v1' && f.path === '.gitignore' && entry?.kind === 'custom' && f.kind === 'managed';
|
|
25
|
+
if (!entry || seen.has(f.path) || (entry.kind !== f.kind && !legacyGitignore) || !sha256.test(f.installedHash) || entry.source !== f.source)
|
|
26
|
+
fail('INVALID_MANIFEST', 'Arquivo inesperado ou inválido no manifesto.');
|
|
27
|
+
if (f.kind === 'managed' && f.installedHash !== hash(entry.content))
|
|
28
|
+
fail('INVALID_MANIFEST', 'Hash base não corresponde ao catálogo distribuído.');
|
|
29
|
+
// Compatibilidade restrita: hash managed foi validado acima; normalizar só em memória.
|
|
30
|
+
if (legacyGitignore) f.kind = 'custom';
|
|
31
|
+
seen.add(f.path);
|
|
32
|
+
}
|
|
33
|
+
if (seen.size !== entries.size) fail('INVALID_MANIFEST', 'Lista de arquivos incompleta.');
|
|
34
|
+
return m;
|
|
35
|
+
}
|
|
36
|
+
export function makeManifest(catalog, version, track, files) {
|
|
37
|
+
return { manifestVersion: catalog.manifestVersion, layoutVersion: catalog.layoutVersion, cliVersion: version, templateVersion: catalog.templateVersion,
|
|
38
|
+
track, files: files.map(f => ({ path: f.path, kind: f.kind, source: f.source, installedHash: f.installedHash })) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Seleção explícita: nunca reinterpretar manifesto desconhecido como instalação nova.
|
|
42
|
+
export async function installedProfile(root) {
|
|
43
|
+
const raw = await readKnown(root, manifestPath);
|
|
44
|
+
if (!raw) return null;
|
|
45
|
+
let value;
|
|
46
|
+
try { value = JSON.parse(raw.toString('utf8')); }
|
|
47
|
+
catch { fail('INVALID_MANIFEST', 'Manifesto não contém JSON válido.'); }
|
|
48
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('INVALID_MANIFEST', 'Manifesto deve ser objeto JSON.');
|
|
49
|
+
const layout = value.manifestVersion === '1.0.0' ? 'legacy-v1' : value.manifestVersion === '2.0.0' ? 'central-v2' : null;
|
|
50
|
+
if (!layout) fail('UNSUPPORTED_MANIFEST', 'Formato do manifesto não suportado; nenhuma migração será executada.');
|
|
51
|
+
const catalog = await loadCatalog(layout, value.templateVersion);
|
|
52
|
+
const entries = [...catalog.entries, ...contextEntries(value.track, layout)];
|
|
53
|
+
const manifest = await readManifest(root, { ...catalog, entries });
|
|
54
|
+
return { catalog, manifest };
|
|
55
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { evidence, version } from './evidence.js';
|
|
2
|
+
const nodeFrameworks = ['next','react','vue','nuxt','@angular/core','express','fastify','nestjs','@nestjs/core'];
|
|
3
|
+
const phpFrameworks = ['laravel/framework','symfony/framework-bundle','slim/slim','cakephp/cakephp'];
|
|
4
|
+
const drivers = new Map([['mysql','MySQL'],['mysql2','MySQL'],['pg','PostgreSQL'],['sqlite3','SQLite'],['better-sqlite3','SQLite'],['mongodb','MongoDB'],['ext-pdo_mysql','MySQL'],['ext-pdo_pgsql','PostgreSQL'],['ext-pdo_sqlite','SQLite']]);
|
|
5
|
+
export function detect(file) {
|
|
6
|
+
const observations = []; const diagnostics = []; const base = file.path.split('/').at(-1);
|
|
7
|
+
const add = (category,name,value,basis,classification='confirmado',needle='') => observations.push(evidence(file,category,name,value,basis,classification,needle));
|
|
8
|
+
const dependency = (name, constraint, locked=false) => {
|
|
9
|
+
const v=version(constraint);
|
|
10
|
+
if(nodeFrameworks.includes(name)||phpFrameworks.includes(name)) add('framework',name,v,locked?'versão registrada em lockfile':'dependência declarada','confirmado',JSON.stringify(name));
|
|
11
|
+
if(drivers.has(name)) add('database',drivers.get(name),v,'driver declarado; seleção e servidor não comprovados','inferido',JSON.stringify(name));
|
|
12
|
+
};
|
|
13
|
+
let json;
|
|
14
|
+
if(['package.json','package-lock.json','composer.json','composer.lock','Pipfile.lock'].includes(base)) {
|
|
15
|
+
try { json=JSON.parse(file.text); if(!json||typeof json!=='object'||Array.isArray(json))throw new Error(); }
|
|
16
|
+
catch { return {observations,diagnostics:[{code:'MALFORMED_MANIFEST',path:file.path}]}; }
|
|
17
|
+
}
|
|
18
|
+
if(base==='package.json') {
|
|
19
|
+
add('manifest','package.json',null,'manifesto presente');
|
|
20
|
+
const nodeVersion = version(json.engines?.node);
|
|
21
|
+
if (nodeVersion) add('technology','Node.js',nodeVersion,'requisito declarado; runtime instalado não verificado','confirmado','"node"');
|
|
22
|
+
else {
|
|
23
|
+
const declared = json.engines && typeof json.engines === 'object' && Object.hasOwn(json.engines,'node');
|
|
24
|
+
add('technology','Node.js',null,declared ? 'declaração de requisito não interpretável; versão desconhecida' : 'manifesto do ecossistema JavaScript; requisito Node.js não declarado',declared ? 'desconhecido' : 'inferido');
|
|
25
|
+
}
|
|
26
|
+
for(const group of ['dependencies','devDependencies','peerDependencies']) {
|
|
27
|
+
const deps=json[group]; if(!deps||typeof deps!=='object')continue;
|
|
28
|
+
for(const name of [...nodeFrameworks,...drivers.keys()])if(Object.hasOwn(deps,name))dependency(name,deps[name]);
|
|
29
|
+
}
|
|
30
|
+
const manager=/^(npm|pnpm|yarn|bun)@(\d+(?:\.\d+){0,2})$/.exec(typeof json.packageManager==='string'?json.packageManager:'');
|
|
31
|
+
if(manager)add('manager',manager[1],manager[2],'gerenciador declarado','confirmado','"packageManager"');
|
|
32
|
+
}
|
|
33
|
+
if(base==='package-lock.json') {
|
|
34
|
+
add('manager','npm',null,'lockfile presente');
|
|
35
|
+
for(const name of nodeFrameworks) {
|
|
36
|
+
const value=json.packages?.['node_modules/'+name]?.version??json.dependencies?.[name]?.version;
|
|
37
|
+
if(value!==undefined)dependency(name,value,true);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if(['pnpm-lock.yaml','yarn.lock','bun.lock'].includes(base))add('manager',base==='pnpm-lock.yaml'?'pnpm':base==='yarn.lock'?'yarn':'bun',null,'lockfile presente; resolução interna não interpretada');
|
|
41
|
+
if(base==='composer.json') {
|
|
42
|
+
add('manager','Composer',null,'manifesto presente');
|
|
43
|
+
add('technology','PHP',version(json.require?.php),'requisito declarado; runtime instalado não verificado','confirmado','"php"');
|
|
44
|
+
for(const group of ['require','require-dev'])for(const name of [...phpFrameworks,...drivers.keys()])if(json[group]&&Object.hasOwn(json[group],name))dependency(name,json[group][name]);
|
|
45
|
+
}
|
|
46
|
+
if(base==='composer.lock') {
|
|
47
|
+
add('manager','Composer',null,'lockfile presente');
|
|
48
|
+
for(const group of ['packages','packages-dev'])if(Array.isArray(json[group]))for(const p of json[group])if(p&&phpFrameworks.includes(p.name))dependency(p.name,p.version,true);
|
|
49
|
+
}
|
|
50
|
+
if(['.nvmrc','.node-version','.ruby-version'].includes(base))add('technology',base==='.ruby-version'?'Ruby':'Node.js',version(file.text.trim()),'versão declarada em arquivo; instalação desconhecida');
|
|
51
|
+
if(['pyproject.toml','requirements.txt','poetry.lock','Pipfile.lock'].includes(base)) {
|
|
52
|
+
add('technology','Python',null,'manifesto ou lockfile reconhecido');
|
|
53
|
+
add('manager',base==='poetry.lock'?'Poetry':base==='Pipfile.lock'?'Pipenv':base==='requirements.txt'?'pip':'Python packaging',null,'marcador de configuração');
|
|
54
|
+
const requirement=/^\s*requires-python\s*=\s*["']([^"']+)["']/m.exec(file.text);
|
|
55
|
+
if(requirement)add('technology','Python',version(requirement[1]),'requisito declarado em pyproject; instalação desconhecida','confirmado','requires-python');
|
|
56
|
+
for(const framework of ['django','flask','fastapi']) {
|
|
57
|
+
const pattern=new RegExp('(?:^|[\\s"\\\'])'+framework+'(?:[\\s"\\\'[<>=~!]|$)','im');
|
|
58
|
+
if(pattern.test(file.text))add('framework',framework,null,'indício textual limitado; contexto dinâmico não avaliado','inferido',framework);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if(['pom.xml','build.gradle','build.gradle.kts'].includes(base)) {
|
|
62
|
+
add('technology','Java/JVM',null,'arquivo de build presente; linguagem/runtime efetivos não verificados','inferido');
|
|
63
|
+
add('manager',base==='pom.xml'?'Maven':'Gradle',null,'arquivo de build presente');
|
|
64
|
+
const target=/<(?:maven.compiler.release|maven.compiler.source|java.version)>\s*(\d+(?:\.\d+)?)\s*<\//.exec(file.text);
|
|
65
|
+
if(target)add('technology','Java/JVM',target[1],'alvo de compilação declarado; runtime instalado desconhecido');
|
|
66
|
+
if(/org\.springframework|spring-boot/.test(file.text))add('framework','Spring',null,'indício textual de dependência','inferido');
|
|
67
|
+
}
|
|
68
|
+
if(['Gemfile','Gemfile.lock'].includes(base)) {
|
|
69
|
+
add('technology','Ruby',null,'manifesto ou lockfile presente');add('manager','Bundler',null,'manifesto ou lockfile presente');
|
|
70
|
+
if(/(?:^\s*gem\s+['"]rails['"]|^\s+rails\s+\()/m.test(file.text))add('framework','Rails',null,'indício estático de dependência','inferido');
|
|
71
|
+
}
|
|
72
|
+
if(base==='Dockerfile'||/^(?:docker-)?compose\.ya?ml$/.test(base)) {
|
|
73
|
+
add('container','Docker',null,'arquivo de configuração presente; execução não verificada');
|
|
74
|
+
const result = containerImages(file.text, base === 'Dockerfile');
|
|
75
|
+
for (const { token, line } of result.images) {
|
|
76
|
+
// Valores e produtos da saída continuam restritos à lista anterior.
|
|
77
|
+
const image=/^(node|php|python|ruby|openjdk|eclipse-temurin|mysql|mariadb|postgres|mongo|redis)(?::(\d+(?:\.\d+){0,3})(?:-[a-z0-9.-]+)?)?$/.exec(token);
|
|
78
|
+
if (!image) { result.ambiguous = true; continue; }
|
|
79
|
+
const db=['mysql','mariadb','postgres','mongo','redis'].includes(image[1]);
|
|
80
|
+
add(db?'database':'technology',image[1],image[2]??null,'imagem declarada no contexto estático suportado; serviço e runtime ativos não verificados','confirmado',base==='Dockerfile'?'FROM':'image');
|
|
81
|
+
observations.at(-1).evidence.line = line;
|
|
82
|
+
}
|
|
83
|
+
if (result.ambiguous) {
|
|
84
|
+
add('container-image','selection',null,'sintaxe de imagem não suportada ou ambígua; nenhuma resolução dinâmica','desconhecido');
|
|
85
|
+
diagnostics.push({code:'CONTAINER_SYNTAX_UNSUPPORTED',path:file.path});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { observations, diagnostics };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Reconhecedor conservador: não é parser geral Dockerfile/YAML.
|
|
92
|
+
function containerImages(text, dockerfile) {
|
|
93
|
+
const lines = text.split(/\r?\n/);
|
|
94
|
+
const images = [];
|
|
95
|
+
if (dockerfile) {
|
|
96
|
+
// Conteúdo de heredoc e continuações não são instruções independentes.
|
|
97
|
+
// Sem interpretar shell/delimitadores, tornar todas as imagens desconhecidas.
|
|
98
|
+
if (lines.some(line => /^\s*#\s*escape\s*=/i.test(line) ||
|
|
99
|
+
(!/^\s*#/.test(line) && (line.includes('<<') || /\\\s*$/.test(line)))))
|
|
100
|
+
return {images:[],ambiguous:true};
|
|
101
|
+
let ambiguous = false;
|
|
102
|
+
const stageAliases = new Set();
|
|
103
|
+
for (let i = 0; i < lines.length; i++) {
|
|
104
|
+
if (!/^\s*FROM\b/i.test(lines[i])) continue;
|
|
105
|
+
const match = /^\s*FROM\s+(?:--platform=[a-zA-Z0-9_.\/-]+\s+)?([^\s#]+)(?:\s+AS\s+([a-zA-Z0-9_.-]+))?\s*(?:#.*)?$/i.exec(lines[i]);
|
|
106
|
+
if (match) {
|
|
107
|
+
// Somente aliases definidos anteriormente; imagem externa com tag não
|
|
108
|
+
// coincide com alias. Não expor nem resolver conteúdo do stage local.
|
|
109
|
+
if (!stageAliases.has(match[1].toLowerCase())) images.push({token:match[1],line:i+1});
|
|
110
|
+
if (match[2]) stageAliases.add(match[2].toLowerCase());
|
|
111
|
+
} else ambiguous = true;
|
|
112
|
+
}
|
|
113
|
+
return {images,ambiguous};
|
|
114
|
+
}
|
|
115
|
+
// YAML suportado: services na raiz, serviço a dois espaços, image a quatro.
|
|
116
|
+
// Tabulação, fluxo, aliases, documentos múltiplos e indentação alternativa
|
|
117
|
+
// são conservadoramente desconhecidos, sem interpretar nem seguir referências.
|
|
118
|
+
if (lines.some(line => !/^\s*#/.test(line) &&
|
|
119
|
+
(/\t/.test(line) || /^ */.exec(line)[0].length % 2 !== 0 ||
|
|
120
|
+
(/:\s*["']/.test(line) && !/^ image:/.test(line)) || /^\s*(?:---|\.\.\.)(?:\s|$)/.test(line) || /[&*][a-zA-Z_]|<<:/.test(line))))
|
|
121
|
+
return {images:[],ambiguous:true};
|
|
122
|
+
let services = false; let service = false; let scalarIndent = null; let ambiguous = false;
|
|
123
|
+
let servicesCount = 0; let seenImage = false; const serviceNames = new Set();
|
|
124
|
+
for (let i = 0; i < lines.length; i++) {
|
|
125
|
+
const line = lines[i];
|
|
126
|
+
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
127
|
+
const indent = /^ */.exec(line)[0].length;
|
|
128
|
+
if (scalarIndent !== null) {
|
|
129
|
+
if (indent > scalarIndent) continue;
|
|
130
|
+
scalarIndent = null;
|
|
131
|
+
}
|
|
132
|
+
if (/^[^#]*:\s*[|>][+-]?[1-9]?\s*(?:#.*)?$/.test(line)) scalarIndent = indent;
|
|
133
|
+
if (indent === 0) {
|
|
134
|
+
services = /^services:\s*(?:#.*)?$/.test(line); service = false;
|
|
135
|
+
if (services && ++servicesCount > 1) ambiguous = true;
|
|
136
|
+
if (/^services:/.test(line) && !services) ambiguous = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!services) continue;
|
|
140
|
+
if (indent <= 2) {
|
|
141
|
+
const name = /^ ([a-zA-Z0-9_.-]+):\s*(?:#.*)?$/.exec(line);
|
|
142
|
+
service = !!name; seenImage = false;
|
|
143
|
+
if (name) { if (serviceNames.has(name[1])) ambiguous = true; serviceNames.add(name[1]); }
|
|
144
|
+
if (!service) ambiguous = true;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (!service) { ambiguous = true; continue; }
|
|
148
|
+
// image em environment, labels, extensões ou blocos literais não é imagem de serviço.
|
|
149
|
+
if (/^ image:/.test(line)) {
|
|
150
|
+
if (seenImage) ambiguous = true; seenImage = true;
|
|
151
|
+
const value = /^ image:\s*(?:"([^"\\]*)"|'([^']*)'|([^\s"'#]+))\s*(?:#.*)?$/.exec(line);
|
|
152
|
+
if (value) images.push({token:value[1]??value[2]??value[3],line:i+1}); else ambiguous = true;
|
|
153
|
+
} else if (indent === 4 && /^ ["']image["']:/.test(line)) ambiguous = true;
|
|
154
|
+
}
|
|
155
|
+
return {images:ambiguous ? [] : images,ambiguous};
|
|
156
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Nunca encaminhar texto arbitrário do projeto ao relatório.
|
|
2
|
+
export function version(value) {
|
|
3
|
+
return typeof value === 'string' && /\d/.test(value) && /^[v~^<>=0-9xX.*| +,-]{1,64}$/.test(value) ? value : null;
|
|
4
|
+
}
|
|
5
|
+
export function evidence(file, category, name, value, basis, classification = 'confirmado', needle = '') {
|
|
6
|
+
const index = needle ? file.text.indexOf(needle) : -1;
|
|
7
|
+
return { category, name, value, classification, basis, evidence: { path: file.path,
|
|
8
|
+
...(index >= 0 ? { line: file.text.slice(0,index).split('\n').length } : {}), locator: needle || 'file' } };
|
|
9
|
+
}
|
|
10
|
+
export function unknown(category, name, basis) {
|
|
11
|
+
return { category, name, value: null, classification: 'desconhecido', basis, evidence: null };
|
|
12
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { checkedPath, safeRelative } from '../platform/filesystem.js';
|
|
5
|
+
import { fail } from '../core/errors.js';
|
|
6
|
+
export const limits = { files: 256, fileBytes: 2 * 1024 * 1024, totalBytes: 8 * 1024 * 1024, modules: 64 };
|
|
7
|
+
export async function readCandidate(root, relative, budget) {
|
|
8
|
+
const file = await checkedPath(root, relative);
|
|
9
|
+
let info;
|
|
10
|
+
try { info = await fs.lstat(file); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
|
11
|
+
if (!info.isFile() || info.nlink !== 1 || await fs.realpath(file) !== file)
|
|
12
|
+
fail('DISCOVERY_UNSAFE_PATH', 'Candidato indireto ou não regular; análise bloqueada.');
|
|
13
|
+
if (++budget.files > limits.files || info.size > limits.fileBytes || (budget.bytes += info.size) > limits.totalBytes)
|
|
14
|
+
fail('DISCOVERY_LIMIT', 'Limite de leitura estática excedido; análise incompleta.');
|
|
15
|
+
const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
16
|
+
try {
|
|
17
|
+
const opened = await handle.stat();
|
|
18
|
+
if (opened.ino !== info.ino || opened.dev !== info.dev || opened.nlink !== 1 || opened.size !== info.size)
|
|
19
|
+
fail('DISCOVERY_UNSAFE_PATH', 'Candidato mudou durante a validação.');
|
|
20
|
+
// Leitura limitada mesmo se outro processo ampliar o arquivo após stat.
|
|
21
|
+
const bytes = Buffer.alloc(info.size + 1);
|
|
22
|
+
let offset = 0;
|
|
23
|
+
while (offset < bytes.length) {
|
|
24
|
+
const result = await handle.read(bytes, offset, bytes.length - offset, null);
|
|
25
|
+
if (!result.bytesRead) break;
|
|
26
|
+
offset += result.bytesRead;
|
|
27
|
+
}
|
|
28
|
+
const after = await handle.stat();
|
|
29
|
+
if (offset !== info.size || after.size !== info.size || after.mtimeMs !== opened.mtimeMs || after.nlink !== 1)
|
|
30
|
+
fail('DISCOVERY_UNSAFE_PATH', 'Candidato mudou durante a leitura.');
|
|
31
|
+
return bytes.subarray(0, offset).toString('utf8');
|
|
32
|
+
} finally { await handle.close(); }
|
|
33
|
+
}
|
|
34
|
+
export async function safeDirectory(root, relative) {
|
|
35
|
+
if (!safeRelative(relative)) fail('UNSAFE_PATH', 'Caminho fora do contrato do template.');
|
|
36
|
+
let candidate = root;
|
|
37
|
+
// Validar somente os componentes reais, inclusive raiz, sem nome auxiliar.
|
|
38
|
+
for (const part of [null, ...relative.split('/')]) {
|
|
39
|
+
if (part !== null) candidate = path.join(candidate, part);
|
|
40
|
+
let info;
|
|
41
|
+
try { info = await fs.lstat(candidate); }
|
|
42
|
+
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
|
43
|
+
if (!info.isDirectory() || info.isSymbolicLink() || await fs.realpath(candidate) !== candidate)
|
|
44
|
+
fail('DISCOVERY_UNSAFE_PATH', 'Diretório indireto ou não regular; análise bloqueada.');
|
|
45
|
+
}
|
|
46
|
+
return candidate;
|
|
47
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { unknown } from './evidence.js';
|
|
2
|
+
export function reconcile(observations, diagnostics) {
|
|
3
|
+
// Não escolher silenciosamente uma versão entre requisitos, lock e imagem.
|
|
4
|
+
const groups = new Map();
|
|
5
|
+
for(const item of observations) {
|
|
6
|
+
const key=item.category+':'+item.name;
|
|
7
|
+
if(!groups.has(key))groups.set(key,new Set());
|
|
8
|
+
if(item.value!==null)groups.get(key).add(item.value);
|
|
9
|
+
}
|
|
10
|
+
for(const [key,values] of groups)if(values.size>1)diagnostics.push({code:'MULTIPLE_VERSION_EVIDENCE',subject:key});
|
|
11
|
+
observations.push(unknown('runtime','installed','Análise estática não verifica runtimes ou pacotes instalados.'),
|
|
12
|
+
unknown('database','active','Sem leitura de credenciais, conexão ou execução de banco.'),
|
|
13
|
+
unknown('ci','execution','Presença de configuração não comprova execução ou sucesso.'));
|
|
14
|
+
if(!observations.some(o=>o.category==='framework'))observations.push(unknown('framework','selection','Nenhum framework reconhecido; ausência não comprovada.'));
|
|
15
|
+
observations.sort((a,b)=>JSON.stringify(a).localeCompare(JSON.stringify(b),'en'));
|
|
16
|
+
diagnostics.sort((a,b)=>JSON.stringify(a).localeCompare(JSON.stringify(b),'en'));
|
|
17
|
+
return {observations,diagnostics};
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { inspectDiscoveryGit } from '../platform/git.js';
|
|
2
|
+
import { fail } from '../core/errors.js';
|
|
3
|
+
import { resolveTarget } from '../platform/filesystem.js';
|
|
4
|
+
import { scan } from './scanner.js';
|
|
5
|
+
import { detect } from './detectors.js';
|
|
6
|
+
import { reconcile } from './reconcile.js';
|
|
7
|
+
export async function discover(target, allowNoGit = false) {
|
|
8
|
+
const root=await resolveTarget(target);
|
|
9
|
+
const git=await inspectDiscoveryGit(root,allowNoGit);
|
|
10
|
+
if(!git.safe)fail(git.code,git.message,git.exitCode);
|
|
11
|
+
const inventory=await scan(root);
|
|
12
|
+
const observations=[];const diagnostics=[...inventory.diagnostics];
|
|
13
|
+
for(const file of inventory.files) {
|
|
14
|
+
const result=detect(file);observations.push(...result.observations);diagnostics.push(...result.diagnostics);
|
|
15
|
+
}
|
|
16
|
+
for(const item of inventory.ci)observations.push({category:'ci',name:item.name,value:null,classification:'confirmado',basis:'arquivo presente; conteúdo e execução não verificados',evidence:{path:item.path,locator:'presence'}});
|
|
17
|
+
const report=reconcile(observations,diagnostics);
|
|
18
|
+
return {ok:true,exitCode:0,reportVersion:'1.0.0',mode:git.mode,head:git.head??'unknown',readOnly:true,
|
|
19
|
+
databasePolicy:'MANUAL_SQL',approvals:'Nenhuma aprovação ou execução de banco concedida.',
|
|
20
|
+
coverage:{profile:'bounded-static-v1',filesRead:inventory.budget.files,bytesRead:inventory.budget.bytes,
|
|
21
|
+
moduleContainers:['apps','packages','services'],partial:report.diagnostics.length>0,
|
|
22
|
+
excluded:['credentials','environment files','installed dependencies','workflow payloads','external references']},
|
|
23
|
+
...report,message:git.mode==='no-git'?'Discovery limitado sem Git; raiz fornecida não comprova identidade do projeto.':'Discovery estático somente leitura; fatos declarados não comprovam execução.'};
|
|
24
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readCandidate, safeDirectory, limits } from './reader.js';
|
|
4
|
+
import { fail } from '../core/errors.js';
|
|
5
|
+
const candidates = ['package.json','package-lock.json','pnpm-lock.yaml','yarn.lock','bun.lock','composer.json','composer.lock','.nvmrc','.node-version',
|
|
6
|
+
'pyproject.toml','requirements.txt','poetry.lock','Pipfile.lock','pom.xml','build.gradle','build.gradle.kts',
|
|
7
|
+
'Gemfile','Gemfile.lock','.ruby-version','Dockerfile','compose.yml','compose.yaml','docker-compose.yml','docker-compose.yaml'];
|
|
8
|
+
export async function scan(root) {
|
|
9
|
+
const files = []; const diagnostics = []; const budget = { files: 0, bytes: 0 }; let modules = 0;
|
|
10
|
+
async function visit(prefix = '') {
|
|
11
|
+
if (prefix) {
|
|
12
|
+
// Não entrar em outro repositório, mesmo sem Git no alvo principal.
|
|
13
|
+
if (await fs.lstat(path.join(root, prefix, '.git')).catch(e => { if (e.code === 'ENOENT') return null; throw e; })) {
|
|
14
|
+
diagnostics.push({ code: 'NESTED_PROJECT_EXCLUDED', path: prefix }); return;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
for (const name of candidates) {
|
|
18
|
+
const relative = prefix ? prefix + '/' + name : name;
|
|
19
|
+
const text = await readCandidate(root, relative, budget);
|
|
20
|
+
if (text !== null) files.push({ path: relative, text });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
await visit();
|
|
24
|
+
// Descoberta limitada: módulos diretamente sob três contêineres conhecidos.
|
|
25
|
+
for (const container of ['apps','packages','services']) {
|
|
26
|
+
const dir = await safeDirectory(root, container); if (!dir) continue;
|
|
27
|
+
// Limitar entradas antes de acumular listagem em memória.
|
|
28
|
+
const listing = await fs.opendir(dir);
|
|
29
|
+
for await (const child of listing) {
|
|
30
|
+
if (++modules > limits.modules) fail('DISCOVERY_LIMIT', 'Limite de entradas de módulos excedido.');
|
|
31
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(child.name)) { diagnostics.push({ code: 'MODULE_NAME_EXCLUDED' }); continue; }
|
|
32
|
+
const relative = container + '/' + child.name;
|
|
33
|
+
if (child.isSymbolicLink()) fail('DISCOVERY_UNSAFE_PATH', 'Link em módulo selecionado.');
|
|
34
|
+
if (child.isDirectory() && await safeDirectory(root, relative)) await visit(relative);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// CI: somente presença de arquivos, sem leitura de YAML/scripts potencialmente sensíveis.
|
|
38
|
+
const ci = [];
|
|
39
|
+
const workflow = await safeDirectory(root, '.github/workflows');
|
|
40
|
+
if (workflow) {
|
|
41
|
+
let count = 0;
|
|
42
|
+
const listing = await fs.opendir(workflow);
|
|
43
|
+
for await (const entry of listing) {
|
|
44
|
+
if (++count > limits.files) fail('DISCOVERY_LIMIT', 'Limite de entradas CI excedido.');
|
|
45
|
+
if (!/^[a-zA-Z0-9_.-]{1,80}\.ya?ml$/.test(entry.name)) continue;
|
|
46
|
+
const file = path.join(workflow, entry.name); const info = await fs.lstat(file);
|
|
47
|
+
if (!info.isFile() || info.nlink !== 1 || info.isSymbolicLink()) fail('DISCOVERY_UNSAFE_PATH', 'Arquivo CI indireto.');
|
|
48
|
+
ci.push({ name: 'GitHub Actions', path: '.github/workflows/' + entry.name });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const [name, relative] of [['GitLab CI','.gitlab-ci.yml'],['Jenkins','Jenkinsfile']]) {
|
|
52
|
+
const { checkedPath } = await import('../platform/filesystem.js');
|
|
53
|
+
const file = await checkedPath(root, relative);
|
|
54
|
+
const info = await fs.lstat(file).catch(e => { if (e.code === 'ENOENT') return null; throw e; });
|
|
55
|
+
if (!info) continue;
|
|
56
|
+
if (!info.isFile() || info.nlink !== 1) fail('DISCOVERY_UNSAFE_PATH', 'Arquivo CI indireto.');
|
|
57
|
+
ci.push({ name, path: relative });
|
|
58
|
+
}
|
|
59
|
+
return { files, diagnostics, ci, budget };
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const renderJson = result => JSON.stringify(result, null, 2) + '\n';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function renderText(result) {
|
|
2
|
+
const lines = [result.message ?? (result.ok ? 'OK' : 'Falha')];
|
|
3
|
+
if (result.code) lines.push(`Código: ${result.code}`);
|
|
4
|
+
for (const field of ['layoutVersion', 'templateVersion', 'manifestVersion', 'installedTrack'])
|
|
5
|
+
if (result[field]) lines.push(`${field}: ${result[field]}`);
|
|
6
|
+
for (const e of result.operations ?? []) lines.push(`${e.action}: ${e.path}`);
|
|
7
|
+
for (const e of result.files ?? []) lines.push(`${e.state} (${e.kind}): ${e.path}`);
|
|
8
|
+
for (const e of result.findings ?? []) lines.push(`${e.code}: ${e.path}${e.reference ? ` -> ${e.reference}` : ''}`);
|
|
9
|
+
for (const e of result.checks ?? []) {
|
|
10
|
+
lines.push(`${e.ok ? 'OK' : 'FALHA'} ${e.code}: ${e.message}`);
|
|
11
|
+
for (const finding of e.findings ?? []) lines.push(` ${finding.code}: ${finding.path}${finding.reference ? ` -> ${finding.reference}` : ''}`);
|
|
12
|
+
}
|
|
13
|
+
for (const e of result.observations ?? []) lines.push(`${e.classification}: ${e.category}/${e.name} = ${e.value ?? 'não determinado'} (${e.basis})${e.evidence ? ` [${e.evidence.path}${e.evidence.line ? ':' + e.evidence.line : ''}]` : ''}`);
|
|
14
|
+
for (const e of result.diagnostics ?? []) lines.push(`Diagnóstico: ${e.code}${e.path ? ' [' + e.path + ']' : ''}`);
|
|
15
|
+
if (result.persistence) lines.push(`Persistência: ${result.persistence.ok?'concluída':'não concluída'}${result.persistence.current?' / Discovery '+result.persistence.current:''}${result.persistence.code?' / '+result.persistence.code:''}`);
|
|
16
|
+
if (result.created?.length) lines.push(`Criados: ${result.created.join(', ')}`);
|
|
17
|
+
return lines.join('\n') + '\n';
|
|
18
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { fail } from '../core/errors.js';
|
|
2
|
+
import { limits } from './storage.js';
|
|
3
|
+
export const commands=['init','discover','doctor','validate','status'];
|
|
4
|
+
export const uuid=/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
5
|
+
const sha=/^[a-f0-9]{64}$/;
|
|
6
|
+
const keys=(value,allowed)=>value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).every(k=>allowed.includes(k));
|
|
7
|
+
export function parseHistory(data){
|
|
8
|
+
if(!data?.length)return [];
|
|
9
|
+
const text=data.toString('utf8');
|
|
10
|
+
if(!Buffer.from(text).equals(data)||!text.endsWith('\n'))fail('HISTORY_INVALID','Histórico inválido/truncado; nenhuma correção automática.');
|
|
11
|
+
const events=[];const seen=new Set();
|
|
12
|
+
for(const line of text.slice(0,-1).split('\n')){
|
|
13
|
+
if(Buffer.byteLength(line+'\n')>limits.event)fail('PERSISTENCE_LIMIT','Evento excede limite.');
|
|
14
|
+
let e;try{e=JSON.parse(line);}catch{fail('HISTORY_INVALID','Histórico não contém JSONL válido.');}
|
|
15
|
+
if(!keys(e,['historyVersion','id','timestamp','cliVersion','command','result','operationExitCode','summaryCode','discovery'])||e.historyVersion!=='1.0.0'||typeof e.id!=='string'||!uuid.test(e.id)||seen.has(e.id)||
|
|
16
|
+
typeof e.timestamp!=='string'||!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(e.timestamp)||!Number.isFinite(Date.parse(e.timestamp))||new Date(e.timestamp).toISOString()!==e.timestamp||
|
|
17
|
+
typeof e.cliVersion!=='string'||!/^\d{1,6}\.\d{1,6}\.\d{1,6}$/.test(e.cliVersion)||!commands.includes(e.command)||![0,1,2,3].includes(e.operationExitCode)||
|
|
18
|
+
e.result!==(e.operationExitCode===0?'completed':e.operationExitCode===1?'error':'blocked')||e.summaryCode!==['COMPLETED','OPERATIONAL_ERROR','BLOCKED','INVALID_ARGUMENTS'][e.operationExitCode])fail('HISTORY_INVALID','Schema de histórico inválido; revisão humana necessária.');
|
|
19
|
+
if(e.discovery!==undefined&&(!keys(e.discovery,['snapshot','sha256','current'])||e.command!=='discover'||e.operationExitCode!==0||typeof e.discovery.snapshot!=='string'||!uuid.test(e.discovery.snapshot.slice(0,-3))||!e.discovery.snapshot.endsWith('.md')||e.discovery.snapshot!==e.id+'.md'||typeof e.discovery.sha256!=='string'||!sha.test(e.discovery.sha256)||!['created','updated','preserved-custom'].includes(e.discovery.current)))fail('HISTORY_INVALID','Referência de Discovery inválida.');
|
|
20
|
+
seen.add(e.id);events.push(e);
|
|
21
|
+
}
|
|
22
|
+
return events;
|
|
23
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { version } from '../discovery/evidence.js';
|
|
2
|
+
import { safeRelative } from '../platform/filesystem.js';
|
|
3
|
+
import { limits } from './storage.js';
|
|
4
|
+
import { fail } from '../core/errors.js';
|
|
5
|
+
const names = new Set(['Node.js','PHP','Python','Java/JVM','Ruby','next','react','vue','nuxt','@angular/core','express','fastify','nestjs','@nestjs/core','laravel/framework','symfony/framework-bundle','slim/slim','cakephp/cakephp','django','flask','fastapi','Spring','Rails','MySQL','PostgreSQL','SQLite','MongoDB','mysql','mariadb','postgres','mongo','redis','node','php','python','ruby','openjdk','eclipse-temurin','npm','pnpm','yarn','bun','Composer','Poetry','Pipenv','pip','Python packaging','Maven','Gradle','Bundler','Docker','GitHub Actions','GitLab CI','Jenkins','installed','active','execution','selection','package.json']);
|
|
6
|
+
const bases = {technology:'Declaração/indício estático; instalação desconhecida.',framework:'Dependência/indício estático; revisão humana necessária.',database:'Driver/configuração; banco ativo desconhecido.',ci:'Presença/estado não funcional; execução não comprovada.',manager:'Manifesto/lock; gerenciador não executado.',container:'Configuração presente; nenhum build executado.',manifest:'Manifesto presente.',runtime:'Não verificado.', 'container-image':'Sintaxe não suportada ou ambígua.'};
|
|
7
|
+
const escape = value=>String(value).replace(/[\u0000-\u001f\u007f]/g,' ').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/[\\`|\[\]*_]/g,'\\$&');
|
|
8
|
+
export function discoveryMarkdown(report,id,timestamp){
|
|
9
|
+
const lines=['# Discovery — AI Dev Workflow','',`Registro: ${id}`,`Data UTC: ${timestamp}`,'Status G1: pendente. Política padrão MANUAL_SQL; este relatório não seleciona modo nem concede execução de banco.','',
|
|
10
|
+
'## Stack, declarações e evidências','Confirmado significa fato declarado no arquivo; não comprova runtime instalado ou serviço ativo.','',
|
|
11
|
+
'| Categoria | Tecnologia | Classificação | Valor declarado | Evidência | Limitação / base |','|---|---|---|---|---|---|'];
|
|
12
|
+
if(!Array.isArray(report.observations)||report.observations.length>10000)fail('PERSISTENCE_LIMIT','Relatório excede cobertura permitida.');
|
|
13
|
+
for(const o of report.observations){
|
|
14
|
+
if(!['confirmado','inferido','desconhecido'].includes(o.classification))fail('DISCOVERY_INVALID','Classificação inválida.');
|
|
15
|
+
// Somente a projeção segura do detector, nunca objeto/saída bruta.
|
|
16
|
+
const category=Object.hasOwn(bases,o.category)?o.category:'unknown';
|
|
17
|
+
const name=names.has(o.name)?o.name:'não reconhecido';
|
|
18
|
+
let evidence='não disponível';
|
|
19
|
+
if(o.evidence&&typeof o.evidence.path==='string'&&safeRelative(o.evidence.path))evidence=o.evidence.path+(Number.isSafeInteger(o.evidence.line)&&o.evidence.line>0?':'+o.evidence.line:'');
|
|
20
|
+
lines.push('| '+[category,name,o.classification,version(o.value)??'não determinado',evidence,bases[category]??'Não interpretado.'].map(escape).join(' | ')+' |');
|
|
21
|
+
}
|
|
22
|
+
lines.push('','## Limitações','Análise estática limitada; sem leitura de credenciais, execução de código, rede ou banco.','Runtimes instalados, banco ativo e execução de CI permanecem desconhecidos.','Não substitui Discovery humano completo, baseline funcional ou aprovação G1.','Campos não reconhecidos e formatos dinâmicos não são resolvidos.');
|
|
23
|
+
if(report.coverage?.partial)lines.push('Cobertura parcial: revisar os diagnósticos e exclusões no resultado da CLI.');
|
|
24
|
+
const data=Buffer.from(lines.join('\n')+'\n');if(data.length>limits.report)fail('PERSISTENCE_LIMIT','Relatório excede limite.');return data;
|
|
25
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { checkedPath, stat, hash } from '../platform/filesystem.js';
|
|
5
|
+
import { inspectGit, inspectDiscoveryGit } from '../platform/git.js';
|
|
6
|
+
import { assertReconciled } from '../core/journal.js';
|
|
7
|
+
import { packageVersion } from '../core/catalog.js';
|
|
8
|
+
import { fail } from '../core/errors.js';
|
|
9
|
+
import { paths, limits, read, absent, parent, exclusive, publish, syncParent } from './storage.js';
|
|
10
|
+
import { parseHistory } from './history.js';
|
|
11
|
+
import { discoveryMarkdown } from './markdown.js';
|
|
12
|
+
async function safety(root,command,allowNoGit){
|
|
13
|
+
const noGit=command==='discover'&&allowNoGit&&!await stat(path.join(root,'.git'));
|
|
14
|
+
const g=noGit?await inspectDiscoveryGit(root,true):await inspectGit(root);
|
|
15
|
+
if(!g.safe)fail(g.code,'Persistência exige alvo Git seguro e branch de task, ou Discovery sem Git explicitamente permitido.',g.exitCode);
|
|
16
|
+
await assertReconciled(root);
|
|
17
|
+
await absent(root,paths.journal);
|
|
18
|
+
}
|
|
19
|
+
export async function preflight(root,command,allowNoGit){
|
|
20
|
+
await safety(root,command,allowNoGit);await absent(root,paths.lock);
|
|
21
|
+
parseHistory(await read(root,paths.history,limits.history));
|
|
22
|
+
if(command==='discover')await read(root,paths.current,limits.report);
|
|
23
|
+
}
|
|
24
|
+
export async function persist(root,command,result,allowNoGit=false){
|
|
25
|
+
await safety(root,command,allowNoGit);
|
|
26
|
+
const id=randomUUID();let lockInfo;let locked=false;let journalCreated=false;let preserveLock=false;
|
|
27
|
+
try{
|
|
28
|
+
await parent(root,paths.lock);
|
|
29
|
+
// exclusive cria antes de escrever; guardar propriedade mesmo se a escrita falhar.
|
|
30
|
+
const file=await checkedPath(root,paths.lock);let h;
|
|
31
|
+
try{h=await fs.open(file,'wx',0o600);}catch(e){if(e.code==='EEXIST')fail('PERSISTENCE_LOCKED','Outro gravador ou lock antigo está presente.');throw e;}
|
|
32
|
+
locked=true;
|
|
33
|
+
try{lockInfo=await h.stat();await h.writeFile(id+'\n');await h.sync();}finally{await h.close();}
|
|
34
|
+
await safety(root,command,allowNoGit);
|
|
35
|
+
const history=await read(root,paths.history,limits.history);const events=parseHistory(history);
|
|
36
|
+
const operationExitCode=result.exitCode??(result.ok?0:2);
|
|
37
|
+
const cliVersion=await packageVersion();
|
|
38
|
+
// Sob lock: instante de preparação do registro, não do comando. O relógio pode regredir.
|
|
39
|
+
const timestamp=new Date().toISOString();
|
|
40
|
+
const event={historyVersion:'1.0.0',id,timestamp,cliVersion,command,
|
|
41
|
+
result:operationExitCode===0?'completed':operationExitCode===1?'error':'blocked',operationExitCode,
|
|
42
|
+
summaryCode:['COMPLETED','OPERATIONAL_ERROR','BLOCKED','INVALID_ARGUMENTS'][operationExitCode]};
|
|
43
|
+
const entries=[];let canonical='not-applicable';
|
|
44
|
+
if(command==='discover'&&operationExitCode===0){
|
|
45
|
+
const report=discoveryMarkdown(result,id,timestamp);
|
|
46
|
+
const current=await read(root,paths.current,limits.report);
|
|
47
|
+
const previous=events.filter(e=>e.discovery).at(-1)?.discovery;
|
|
48
|
+
let owned=false;
|
|
49
|
+
if(previous){
|
|
50
|
+
const last=await read(root,paths.versions+'/'+previous.snapshot,limits.report);
|
|
51
|
+
if(!last||hash(last)!==previous.sha256)fail('HISTORY_INVALID','Última versão histórica ausente/adulterada; reconciliação humana necessária.');
|
|
52
|
+
owned=!!current&&hash(current)===previous.sha256;
|
|
53
|
+
}
|
|
54
|
+
canonical=!current?'created':owned?'updated':'preserved-custom';
|
|
55
|
+
event.discovery={snapshot:id+'.md',sha256:hash(report),current:canonical};
|
|
56
|
+
entries.push({path:paths.versions+'/'+id+'.md',data:report,beforeHash:null,maximum:limits.report});
|
|
57
|
+
if(!current||owned)entries.push({path:paths.current,data:report,beforeHash:current?hash(current):null,maximum:limits.report});
|
|
58
|
+
}
|
|
59
|
+
const line=Buffer.from(JSON.stringify(event)+'\n');if(line.length>limits.event)fail('PERSISTENCE_LIMIT','Evento excede limite.');
|
|
60
|
+
const next=Buffer.concat([history??Buffer.alloc(0),line]);if(next.length>limits.history)fail('PERSISTENCE_LIMIT','Histórico excede limite; nenhuma rotação automática.');
|
|
61
|
+
parseHistory(next); // Conferir schema gerado antes de qualquer publicação.
|
|
62
|
+
entries.push({path:paths.history,data:next,beforeHash:history?hash(history):null,maximum:limits.history});
|
|
63
|
+
entries.forEach((e,i)=>{e.temp=path.posix.dirname(e.path)+'/.record-'+id+'-'+i+'.tmp';});
|
|
64
|
+
for(const e of entries){await parent(root,e.path);await absent(root,e.temp);}
|
|
65
|
+
const plan={persistenceVersion:'1.0.0',id,event,files:entries.map(e=>({path:e.path,temp:e.temp,beforeHash:e.beforeHash,plannedHash:hash(e.data)}))};
|
|
66
|
+
// Até um journal vazio/truncado é evidência bloqueante; nunca removê-lo no erro.
|
|
67
|
+
journalCreated=true;await exclusive(root,paths.journal,Buffer.from(JSON.stringify(plan)+'\n'));await syncParent(root,paths.journal);
|
|
68
|
+
for(const entry of entries)await publish(root,entry);
|
|
69
|
+
for(const entry of entries){const value=await read(root,entry.path,entry.maximum);if(!value||hash(value)!==hash(entry.data))fail('PERSISTENCE_INCOMPLETE','Publicação divergente; preservar evidências.');}
|
|
70
|
+
await fs.unlink(await checkedPath(root,paths.journal));
|
|
71
|
+
try { await syncParent(root,paths.journal);journalCreated=false; }
|
|
72
|
+
catch(e) {
|
|
73
|
+
// Resultado do cleanup não comprovado: recolocar marcador ou manter lock.
|
|
74
|
+
try { await exclusive(root,paths.journal,Buffer.from(JSON.stringify(plan)+'\n')); }
|
|
75
|
+
catch { preserveLock=true; }
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
return {ok:true,wrote:true,completed:true,id,history:paths.history,...(event.discovery?{snapshot:paths.versions+'/'+event.discovery.snapshot,current:canonical}:{}),message:'Registros publicados; não representam auditoria completa nem aprovação humana.'};
|
|
79
|
+
}catch(e){
|
|
80
|
+
// Mensagens constantes: não retornar conteúdo de arquivos, argv ou erro bruto.
|
|
81
|
+
if(journalCreated)e.persistenceIncomplete=true;
|
|
82
|
+
throw e;
|
|
83
|
+
}finally{
|
|
84
|
+
if(locked&&!preserveLock){
|
|
85
|
+
const file=await checkedPath(root,paths.lock);const now=await stat(file);
|
|
86
|
+
if(!lockInfo||!now||now.ino!==lockInfo.ino||now.dev!==lockInfo.dev||now.nlink!==1)fail('PERSISTENCE_LOCK_CLEANUP','Lock mudou; não remover automaticamente.',1);
|
|
87
|
+
await fs.unlink(file);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { checkedPath, hash } from '../platform/filesystem.js';
|
|
5
|
+
import { fail } from '../core/errors.js';
|
|
6
|
+
export const paths = { history:'docs/project/COMMAND_HISTORY.jsonl', current:'docs/project/DISCOVERY.md', versions:'docs/project/discovery-history', lock:'docs/project/.command-history.lock', journal:'docs/project/.command-history-journal.json' };
|
|
7
|
+
export const limits = { report:2*1024*1024, event:16*1024, history:8*1024*1024 };
|
|
8
|
+
export async function read(root, relative, maximum) {
|
|
9
|
+
const file=await checkedPath(root,relative);let info;
|
|
10
|
+
try{info=await fs.lstat(file);}catch(e){if(e.code==='ENOENT')return null;throw e;}
|
|
11
|
+
if(info.nlink!==1)fail('PERSISTENCE_UNSAFE','Hardlink em arquivo de persistência.');
|
|
12
|
+
if(info.size>maximum)fail('PERSISTENCE_LIMIT','Limite de persistência excedido.');
|
|
13
|
+
const handle=await fs.open(file,constants.O_RDONLY|(constants.O_NOFOLLOW??0));
|
|
14
|
+
try{
|
|
15
|
+
const opened=await handle.stat();
|
|
16
|
+
if(opened.ino!==info.ino||opened.dev!==info.dev||opened.size!==info.size||opened.nlink!==1)fail('PERSISTENCE_UNSAFE','Arquivo alterado durante validação.');
|
|
17
|
+
const data=Buffer.alloc(info.size+1);let offset=0;
|
|
18
|
+
while(offset<data.length){const r=await handle.read(data,offset,data.length-offset,null);if(!r.bytesRead)break;offset+=r.bytesRead;}
|
|
19
|
+
const after=await handle.stat();
|
|
20
|
+
if(offset!==info.size||after.size!==info.size||after.mtimeMs!==opened.mtimeMs||after.nlink!==1)fail('PERSISTENCE_UNSAFE','Arquivo alterado durante leitura.');
|
|
21
|
+
return data.subarray(0,offset);
|
|
22
|
+
}finally{await handle.close();}
|
|
23
|
+
}
|
|
24
|
+
export async function absent(root,relative){
|
|
25
|
+
if(await fs.lstat(await checkedPath(root,relative)).catch(e=>{if(e.code==='ENOENT')return null;throw e;}))fail('PERSISTENCE_INCOMPLETE','Persistência ocupada/incompleta; preservar evidências e reconciliar manualmente.');
|
|
26
|
+
}
|
|
27
|
+
export async function parent(root,relative){
|
|
28
|
+
const file=await checkedPath(root,relative);await fs.mkdir(path.dirname(file),{recursive:true});await checkedPath(root,relative);return file;
|
|
29
|
+
}
|
|
30
|
+
export async function exclusive(root,relative,data){
|
|
31
|
+
const file=await parent(root,relative);const h=await fs.open(file,'wx',0o600);
|
|
32
|
+
try{await h.writeFile(data);await h.sync();return await h.stat();}finally{await h.close();}
|
|
33
|
+
}
|
|
34
|
+
export async function syncParent(root,relative){
|
|
35
|
+
const dir=path.dirname(await checkedPath(root,relative));const h=await fs.open(dir,'r');try{await h.sync();}finally{await h.close();}
|
|
36
|
+
}
|
|
37
|
+
export async function publish(root,entry){
|
|
38
|
+
await exclusive(root,entry.temp,entry.data);
|
|
39
|
+
const before=await read(root,entry.path,entry.maximum);
|
|
40
|
+
if((before?hash(before):null)!==entry.beforeHash)fail('PERSISTENCE_CONFLICT','Arquivo mudou; publicação bloqueada.');
|
|
41
|
+
const dest=await checkedPath(root,entry.path);const temp=await checkedPath(root,entry.temp);
|
|
42
|
+
if(before){await fs.rename(temp,dest);}else{
|
|
43
|
+
// Publicar sem substituir arquivo preexistente, inclusive UUID colidente.
|
|
44
|
+
await fs.link(temp,dest);await fs.unlink(temp);
|
|
45
|
+
}
|
|
46
|
+
await syncParent(root,entry.path);
|
|
47
|
+
}
|