|
1 | 1 | import fs from 'node:fs'; |
2 | 2 | import path from 'node:path'; |
3 | | -import { spawnSync } from 'node:child_process'; |
4 | 3 | import { active, readStdinJson, resolveWorkspacePath, isWithin, deny } from './docgen-common.mjs'; |
5 | 4 |
|
6 | 5 | const payload = await readStdinJson(); |
7 | 6 | if (!active()) process.exit(0); |
| 7 | + |
8 | 8 | const cwd = path.resolve(payload.cwd ?? process.env.COMMANDCODE_PROJECT_DIR ?? process.cwd()); |
9 | 9 | const input = payload.tool_input ?? {}; |
10 | 10 | const toolName = String(payload.tool_name ?? payload.tool ?? '').toLowerCase(); |
| 11 | +const stage = String(process.env.DOCGEN_STAGE ?? ''); |
| 12 | +const contextOnly = ['1', 'true', 'yes', 'on'].includes(String(process.env.DOCGEN_CONTEXT_ONLY ?? '').toLowerCase()); |
| 13 | +const norm = (value) => String(value ?? '').replaceAll('\\', '/').replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, ''); |
11 | 14 |
|
12 | | -function norm(value) { return String(value ?? '').replaceAll('\\', '/').replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, ''); } |
13 | | -function esc(value) { return value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&'); } |
14 | | -function patternRegex(pattern, anchored = false) { |
15 | | - let body=''; |
16 | | - for(let i=0;i<pattern.length;i++){ |
17 | | - if(pattern[i]==='*'){ if(pattern[i+1]==='*'){while(pattern[i+1]==='*')i++;body+='.*';}else body+='[^/]*'; } |
18 | | - else if(pattern[i]==='?') body+='[^/]'; else body+=esc(pattern[i]); |
19 | | - } |
20 | | - return new RegExp(anchored?`^${body}(?:/.*)?$`:`(?:^|/)${body}(?:/.*)?$`); |
21 | | -} |
22 | | -function rules(file){ |
23 | | - if(!fs.existsSync(file))return[]; |
24 | | - return fs.readFileSync(file,'utf8').split(/\r?\n/).map((raw)=>{ |
25 | | - let line=raw.trim(); if(!line||line.startsWith('#'))return null; let negated=false; |
26 | | - if(line.startsWith('!')){negated=true;line=line.slice(1);} const directoryOnly=line.endsWith('/'); |
27 | | - if(directoryOnly)line=line.replace(/\/+$/,''); const anchored=line.startsWith('/'); if(anchored)line=line.slice(1); |
28 | | - return {raw,negated,directoryOnly,pattern:line,regex:patternRegex(line,anchored)}; |
29 | | - }).filter(Boolean); |
30 | | -} |
31 | | -function matchRules(rel,isDir,items){let decision=null;for(const rule of items){if(rule.directoryOnly&&!isDir&&!rel.startsWith(`${rule.pattern}/`)&&!rel.includes(`/${rule.pattern}/`))continue;if(rule.regex.test(rel))decision={ignored:!rule.negated,reason:`.docgenignore:${rule.raw}`};}return decision;} |
32 | | -function config(){try{return JSON.parse(fs.readFileSync(path.join(cwd,'.docgen','config','documentation.json'),'utf8'));}catch{return{};}} |
33 | | -const binaryExt=new Set(['.png','.jpg','.jpeg','.gif','.webp','.bmp','.ico','.tif','.tiff','.avif','.heic','.psd','.mp3','.wav','.flac','.aac','.ogg','.m4a','.mp4','.mov','.avi','.mkv','.webm','.wmv','.pdf','.doc','.docx','.xls','.xlsx','.ppt','.pptx','.zip','.gz','.tgz','.bz2','.xz','.7z','.rar','.tar','.jar','.war','.ear','.class','.dll','.exe','.so','.dylib','.o','.a','.lib','.woff','.woff2','.ttf','.otf','.eot','.bin','.dat','.db','.sqlite','.sqlite3','.p12','.pfx','.jks','.keystore','.apk','.ipa','.iso','.dmg','.img','.wasm','.pyc']); |
34 | | -function binaryDecision(target,rel,cfg){ |
35 | | - if(cfg.ignore?.binary?.enabled===false||!fs.existsSync(target)||!fs.statSync(target).isFile())return null; |
36 | | - const ext=path.extname(rel).toLowerCase();const deny=new Set((cfg.ignore?.binary?.denyExtensions??[]).map((x)=>String(x).toLowerCase())); |
37 | | - const allow=new Set([...(cfg.sourceExtensions??[]),...(cfg.ignore?.binary?.allowExtensions??[])].map((x)=>String(x).toLowerCase())); |
38 | | - if(deny.has(ext)||(!allow.has(ext)&&binaryExt.has(ext)))return `binary-extension:${ext||'<none>'}`; |
39 | | - const stat=fs.statSync(target);const max=Number(cfg.ignore?.binary?.maxTextFileBytes??4194304);if(stat.size>max)return `text-file-too-large:${stat.size}`; |
40 | | - let buf;try{const fd=fs.openSync(target,'r');buf=Buffer.alloc(Math.min(Number(cfg.ignore?.binary?.probeBytes??16384),stat.size));const n=fs.readSync(fd,buf,0,buf.length,0);fs.closeSync(fd);buf=buf.subarray(0,n);}catch{return'non-text-unreadable';} |
41 | | - const sig=(...xs)=>xs.every((b,i)=>buf[i]===b);if(sig(0x89,0x50,0x4e,0x47)||sig(0xff,0xd8,0xff)||sig(0x25,0x50,0x44,0x46)||sig(0x50,0x4b,0x03,0x04)||sig(0x1f,0x8b)||sig(0x7f,0x45,0x4c,0x46)||sig(0x4d,0x5a)||sig(0x00,0x61,0x73,0x6d))return'binary-magic-signature'; |
42 | | - if(buf.includes(0))return'binary-null-byte'; |
43 | | - try{new TextDecoder('utf-8',{fatal:true}).decode(buf);}catch{return'non-utf8-content';} |
44 | | - return null; |
45 | | -} |
46 | | -function configExcluded(rel,cfg){for(const raw of cfg.exclude??[]){const p=String(raw).replaceAll('\\','/').replace(/^\.\//,'');const cleaned=p.replace(/\/\*\*$/,'').replace(/\/+$/,'');if(patternRegex(cleaned,cleaned.startsWith('/')).test(rel)||rel===cleaned||rel.startsWith(`${cleaned}/`))return `config.exclude:${raw}`;}return null;} |
47 | | -function gitRepositoryAvailable(){ |
48 | | - if(fs.existsSync(path.join(cwd,'.git')))return true; |
49 | | - const r=spawnSync('git',['rev-parse','--is-inside-work-tree'],{cwd,encoding:'utf8',stdio:['ignore','pipe','ignore'],shell:process.platform==='win32'}); |
50 | | - return r.status===0&&String(r.stdout??'').trim()==='true'; |
| 15 | +function inventoryFiles() { |
| 16 | + const file = path.join(cwd, '.docgen', 'index', 'inventory.json'); |
| 17 | + try { return new Set((JSON.parse(fs.readFileSync(file, 'utf8')).files ?? []).map((item) => norm(item.path))); } |
| 18 | + catch { return null; } |
51 | 19 | } |
52 | | -function fallbackGitIgnored(rel){ |
53 | | - const seg=norm(rel).split('/');let ignored=false; |
54 | | - for(let depth=0;depth<seg.length;depth++){const base=seg.slice(0,depth).join('/');const file=path.join(cwd,base,'.gitignore');const m=matchRules(seg.slice(depth).join('/'),false,rules(file));if(m)ignored=m.ignored;} |
55 | | - return ignored; |
56 | | -} |
57 | | -function gitIgnored(rel){ |
58 | | - if(!gitRepositoryAvailable())return fallbackGitIgnored(rel); |
59 | | - const r=spawnSync('git',['check-ignore','--no-index','-q','--',rel],{cwd,stdio:'ignore',shell:process.platform==='win32'}); |
60 | | - if(r.status===0)return true; |
61 | | - if(r.error?.code==='ENOENT'||r.status===127)return fallbackGitIgnored(rel); |
| 20 | + |
| 21 | +function contextAllowed(rel) { |
| 22 | + if (rel === '.docgen/context' || rel.startsWith('.docgen/context/')) return true; |
| 23 | + if (stage === 'modelCore' || stage === 'modelEnterprise') return rel === '.docgen/model' || rel.startsWith('.docgen/model/'); |
| 24 | + if (stage === 'plan') return rel === '.docgen/plan' || rel.startsWith('.docgen/plan/'); |
| 25 | + if (stage === 'generate') return rel === 'docs' || rel.startsWith('docs/') || rel === '.docgen/traceability' || rel.startsWith('.docgen/traceability/'); |
| 26 | + if (stage === 'audit') return rel === 'docs' || rel.startsWith('docs/') || rel === '.docgen/audit' || rel.startsWith('.docgen/audit/'); |
62 | 27 | return false; |
63 | 28 | } |
64 | | -function decision(rawPath){ |
65 | | - const target=resolveWorkspacePath(rawPath,cwd); if(!target)return null; |
66 | | - if(!isWithin(target,cwd))return {ignored:true,reason:'outside repository workspace'}; |
67 | | - const rel=norm(path.relative(cwd,target)); |
68 | | - if(rel==='.docgen'||rel.startsWith('.docgen/')||rel==='docs'||rel.startsWith('docs/'))return {ignored:false}; |
69 | | - const cfg=config(); const hard=['.git','.commandcode','node_modules','target','build','dist','coverage','vendor']; |
70 | | - for(const prefix of hard)if(rel===prefix||rel.startsWith(`${prefix}/`))return {ignored:true,reason:`docgen-hard-exclude:${prefix}/**`}; |
71 | | - const ce=configExcluded(rel,cfg);if(ce)return{ignored:true,reason:ce}; |
72 | | - if(cfg.ignore?.useGitignore!==false&&gitIgnored(rel))return{ignored:true,reason:'.gitignore'}; |
73 | | - if(cfg.ignore?.useDocgenignore!==false){const file=path.join(cwd,cfg.ignore?.docgenignoreFile||'.docgenignore');const d=matchRules(rel,fs.existsSync(target)&&fs.statSync(target).isDirectory(),rules(file));if(d)return d;} |
74 | | - const binary=binaryDecision(target,rel,cfg);if(binary)return{ignored:true,reason:binary}; |
75 | | - return {ignored:false}; |
76 | | -} |
77 | 29 |
|
78 | | -if (['grep','glob','read_multiple_files'].some((x)=>toolName.includes(x))) { |
| 30 | +if (['grep', 'glob', 'read_multiple_files', 'read_directory'].some((name) => toolName.includes(name))) { |
79 | 31 | const explicit = input.path ?? input.directory ?? input.file_path; |
80 | 32 | const pattern = input.pattern ?? input.glob ?? input.query; |
81 | | - if (!explicit || (typeof pattern === 'string' && /[?*\[]/.test(pattern))) { |
82 | | - deny(`DocGen blocks broad ${toolName || 'read/search'} operations because they may include ignored files. Use .docgen/state/source-files.txt, explicit read_file calls, or \`docgen source-grep <text>\`.`); |
| 33 | + if (!explicit || typeof pattern === 'string' && /[?*\[]/.test(pattern)) { |
| 34 | + deny(`DocGen blocks broad ${toolName || 'read/search'} operations. Read one explicit bounded context or inventory-approved source path.`); |
83 | 35 | process.exit(0); |
84 | 36 | } |
85 | 37 | } |
86 | 38 |
|
87 | | -const rawPaths=[]; |
88 | | -for(const key of ['file_path','path','directory']) if(typeof input[key]==='string') rawPaths.push(input[key]); |
89 | | -for(const key of ['paths','files']) if(Array.isArray(input[key])) rawPaths.push(...input[key].filter((x)=>typeof x==='string')); |
90 | | -for(const key of ['pattern','glob']) if(typeof input[key]==='string') { |
91 | | - const value=input[key]; |
92 | | - if(/[?*\[]/.test(value) && !norm(value).startsWith('.docgen/') && !norm(value).startsWith('docs/')) { |
93 | | - deny(`DocGen blocks wildcard source reads because they may bypass .gitignore/.docgenignore. Read .docgen/state/source-files.txt, then read explicit included paths. Blocked pattern: ${value}`); |
94 | | - process.exit(0); |
| 39 | +const rawPaths = []; |
| 40 | +for (const key of ['file_path', 'path', 'directory']) if (typeof input[key] === 'string') rawPaths.push(input[key]); |
| 41 | +for (const key of ['paths', 'files']) if (Array.isArray(input[key])) rawPaths.push(...input[key].filter((value) => typeof value === 'string')); |
| 42 | +for (const key of ['pattern', 'glob']) if (typeof input[key] === 'string') rawPaths.push(input[key]); |
| 43 | + |
| 44 | +const included = inventoryFiles(); |
| 45 | +for (const raw of rawPaths) { |
| 46 | + if (/[?*\[]/.test(raw)) { deny(`DocGen blocks wildcard reads: ${raw}`); process.exit(0); } |
| 47 | + const target = resolveWorkspacePath(raw, cwd); if (!target) continue; |
| 48 | + if (!isWithin(target, cwd)) { deny(`DocGen blocks reads outside the repository: ${raw}`); process.exit(0); } |
| 49 | + const rel = norm(path.relative(cwd, target)); |
| 50 | + if (contextOnly) { |
| 51 | + if (!contextAllowed(rel)) { deny(`Context-only ${stage || 'provider'} run cannot read ${raw}. Allowed inputs are precompiled .docgen/context packs and stage outputs only.`); process.exit(0); } |
| 52 | + continue; |
95 | 53 | } |
96 | | - rawPaths.push(value); |
| 54 | + if (rel === '.docgen' || rel.startsWith('.docgen/') || rel === 'docs' || rel.startsWith('docs/')) continue; |
| 55 | + if (!included) { deny('Source inventory is missing. Run `docgen index` before reading repository source.'); process.exit(0); } |
| 56 | + if (!included.has(rel)) { deny(`DocGen will not read a path outside the canonical source inventory: ${raw}. Inventory: .docgen/index/source-files.txt`); process.exit(0); } |
97 | 57 | } |
98 | | -for(const raw of rawPaths){const d=decision(raw);if(d?.ignored){deny(`DocGen will not read ignored path: ${raw} (${d.reason}). Active source inventory: .docgen/state/source-files.txt`);process.exit(0);}} |
0 commit comments