feat: auto-split monolithic Terraform files and fix single-line HCL blocks

- Add 3-layer protection pipeline for model-generated Terraform code:
  1. Frontend auto-split: splits monolithic HCL into multiple files by resource type
  2. Backend auto-split: Python port as last defense before writing to disk
  3. Backend deduplication: removes monolithic leftovers with duplicate resources
- Fix single-line block expansion to handle ALL HCL blocks (nested included):
  ingress_security_rules, egress_security_rules, route_rules, match_criteria, etc.
- Add prompt size logging for terraform agent diagnostics
- Strengthen system prompt: minimum 3 files, final reminder section
This commit is contained in:
nogueiraguh
2026-03-09 00:40:48 -03:00
parent 15a8366f9c
commit 6fb2eecddf
2 changed files with 323 additions and 19 deletions

View File

@@ -714,11 +714,125 @@ async function loadChatLogs(){
const TF_ICON=`<svg viewBox="0 0 16 16" width="16" height="16"><path d="M5.6 1v4.2l3.6 2.1V3.1L5.6 1zm4.4 2.1v4.2l3.6-2.1V1L10 3.1zM1.4 3.5v4.2l3.6 2.1V5.6L1.4 3.5zM5.6 8.1v4.2L9.2 14.4V10.2L5.6 8.1z" fill="#7b42bc"/></svg>`;
function escHtml(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
function _fixSingleLineBlocks(content){
// Expand ANY single-line HCL block with multiple args into multi-line format.
// Matches both top-level (variable, resource) AND nested blocks (ingress_security_rules, route_rules, etc.)
// Does NOT match map assignments (tags = { ... }) because of the "=" before "{"
// Pattern: identifier ["quoted"]* { key=val, key=val } (no "=" between identifier and "{")
return content.replace(/^(\s*[a-z]\w*(?:\s+"[^"]*")*)\s*\{\s*(.+?)\s*\}\s*$/gm, (match, header, body) => {
// Skip if body doesn't have multiple key=value pairs
let args = body.split(/,\s*/);
if(args.length < 2){
args = [];
const re = /(\w+)\s*=\s*(?:"[^"]*"|\S+)/g;
let m2;
while((m2 = re.exec(body)) !== null) args.push(m2[0]);
}
if(args.length < 2) return match;
const indent = header.match(/^(\s*)/)[1];
return header.trimEnd() + ' {\n' + args.map(a => indent + ' ' + a.trim()).join('\n') + '\n' + indent + '}';
});
}
function _splitTfMonolith(content){
// Split a monolithic HCL file into multiple logical files by parsing top-level blocks.
// Returns array of {name, content} or null if splitting not needed.
content = _fixSingleLineBlocks(content);
const lines=content.split('\n');
const topBlocks=[];
let preamble=[]; // comments/blanks before next block
let accum=[];
let inBlock=false;
let bDepth=0;
let curHeader='';
for(let i=0;i<lines.length;i++){
const line=lines[i];
// Count braces outside of quoted strings (simple heuristic: ignore braces inside "...")
const stripped=line.replace(/"[^"]*"/g,'');
const open=(stripped.match(/\{/g)||[]).length;
const close=(stripped.match(/\}/g)||[]).length;
if(!inBlock){
// Match top-level block declarations only (not attribute assignments like "provider = oci.x")
const hdr=line.match(/^\s*(variable|output|resource|data|provider|module)\s+"/) || line.match(/^\s*(locals|terraform)\s*\{/);
if(hdr){
// Start of a new block — attach preamble (comments above)
inBlock=true;
curHeader=line;
accum=preamble.concat([line]);
preamble=[];
bDepth=open-close;
if(bDepth<=0) bDepth=0;
// Single-line block (opens and closes on same line)
if(bDepth===0 && open>0){
topBlocks.push({header:curHeader,body:accum.join('\n')});
inBlock=false; accum=[];
}
} else {
// Comment, blank, or stray line between blocks — save as preamble for next block
preamble.push(line);
}
} else {
accum.push(line);
bDepth+=open-close;
if(bDepth<=0){
bDepth=0;
topBlocks.push({header:curHeader,body:accum.join('\n')});
inBlock=false; accum=[]; curHeader='';
}
}
}
// Flush remaining
if(accum.length) topBlocks.push({header:curHeader||'',body:accum.join('\n')});
if(topBlocks.length<3) return null;
// Categorize each block by its header
const cats={variables:[],outputs:[],networking:[],compute:[],database:[],
firewall:[],loadbalancer:[],storage:[],iam:[],drg:[],data:[],providers:[],other:[]};
for(const b of topBlocks){
const h=b.header.trim();
if(h.startsWith('variable ')) cats.variables.push(b.body);
else if(h.startsWith('output ')) cats.outputs.push(b.body);
else if(h.startsWith('provider ')) cats.providers.push(b.body);
else if(/resource\s+"oci_core_(drg|remote_peering|drg_route|drg_attachment)/.test(h)) cats.drg.push(b.body);
else if(/resource\s+"oci_core_(vcn|subnet|internet_gateway|nat_gateway|service_gateway|route_table|security_list|network_security_group|dhcp_options|local_peering_gateway|virtual_circuit)/.test(h)) cats.networking.push(b.body);
else if(/resource\s+"oci_core_instance/.test(h)) cats.compute.push(b.body);
else if(/resource\s+"oci_(database|nosql|mysql|psql)/.test(h) || /resource\s+"oci_core_autonomous/.test(h)) cats.database.push(b.body);
else if(/resource\s+"oci_network_firewall/.test(h)) cats.firewall.push(b.body);
else if(/resource\s+"oci_(load_balancer|network_load_balancer)/.test(h)) cats.loadbalancer.push(b.body);
else if(/resource\s+"oci_(objectstorage|file_storage)/.test(h)) cats.storage.push(b.body);
else if(/resource\s+"oci_identity/.test(h)) cats.iam.push(b.body);
else if(h.startsWith('data ')) cats.data.push(b.body);
else cats.other.push(b.body);
}
// Build result files in logical order
const result=[];
const add=(name,arr)=>{if(arr.length) result.push({name,content:arr.join('\n\n')})};
add('variables.tf', cats.variables);
add('providers.tf', cats.providers);
add('networking.tf', cats.networking);
add('drg.tf', cats.drg);
add('compute.tf', cats.compute);
add('database.tf', cats.database);
add('firewall.tf', cats.firewall);
add('loadbalancer.tf', cats.loadbalancer);
add('storage.tf', cats.storage);
add('iam.tf', cats.iam);
add('data.tf', cats.data);
add('main.tf', cats.other);
add('outputs.tf', cats.outputs);
return result.length>=3 ? result : null;
}
function fmTf(t){
// Extract ALL hcl blocks, parse // filename: comments, build separate files
const codeBlocks=[];
let r=t.replace(/```(?:hcl|terraform)\s*\n([\s\S]*?)```/g,(m,code)=>{
const trimmed=code.trim();
const trimmed=_fixSingleLineBlocks(code.trim());
// Check for // filename: comment on first line
const fnMatch=trimmed.match(/^\/\/\s*filename:\s*(\S+)/);
const fileName=fnMatch?fnMatch[1]:'main.tf';
@@ -736,30 +850,52 @@ function fmTf(t){
return '<div class="tf-plan-block"><div class="tf-plan-header">'+TF_ICON+' Resource Plan — '+S.tfPlan.length+' resource(s)</div><div class="tf-plan-list">'+
S.tfPlan.map(x=>'<div class="tf-plan-item"><span class="tf-plan-add">+</span><span class="tf-plan-type">'+escHtml(x.type)+'</span><span class="tf-plan-desc">'+escHtml(x.desc)+'</span></div>').join('')+'</div></div>';
});
// Build files list — merge new blocks with existing files (corrections preserve untouched files)
// Auto-split: if model returned 1-2 large blocks, split into multiple files
// Runs on BOTH first generation and corrections — monolithic responses always get split
let didAutoSplit=false;
if(codeBlocks.length<=2){
const totalContent=codeBlocks.map(b=>b.content).join('\n\n');
if(totalContent.length>2000){
const split=_splitTfMonolith(totalContent);
if(split){
codeBlocks.length=0;
split.forEach(f=>codeBlocks.push(f));
didAutoSplit=true;
// Rebuild HTML with split files
const splitHtml=split.map((f,i)=>
'<div class="tf-code-block"><div class="tf-code-header"><span>'+TF_ICON+' '+escHtml(f.name)+'</span>'+
'<button class="btn" onclick="tfCopyBlock('+i+')">Copy</button></div>'+
'<pre class="tf-pre"><code>'+escHtml(f.content)+'</code></pre></div>'
).join('');
// Replace the original single code block HTML
r=r.replace(/<div class="tf-code-block">[\s\S]*?<\/pre><\/div>/g,'');
r=splitHtml+r;
console.log('[TF] Auto-split monolithic file into '+split.length+' files');
}
}
}
// Build files list
if(codeBlocks.length){
const newMap={};
codeBlocks.forEach(b=>{
if(newMap[b.name])newMap[b.name]+='\n\n'+b.content;
else newMap[b.name]=b.content;
});
// If existing files: merge (update matched, keep unmatched)
if(S.tfFiles.length){
// If auto-split happened, REPLACE all files (monolithic = full regeneration, not partial correction)
// If model returned multiple named files (no auto-split), use merge logic for corrections
if(didAutoSplit || !S.tfFiles.length){
S.tfFiles=Object.entries(newMap).map(([name,content])=>({name,content,size:content.length,type:'hcl'}));
}else{
const merged=[];
const usedNew=new Set();
// Update existing files with new content if name matches
for(const f of S.tfFiles){
if(newMap[f.name]!=null){merged.push({name:f.name,content:newMap[f.name],size:newMap[f.name].length,type:'hcl'});usedNew.add(f.name)}
else{merged.push(f)}
}
// Add any truly new files
for(const[name,content] of Object.entries(newMap)){
if(!usedNew.has(name))merged.push({name,content,size:content.length,type:'hcl'})
}
S.tfFiles=merged;
}else{
// First generation: set all
S.tfFiles=Object.entries(newMap).map(([name,content])=>({name,content,size:content.length,type:'hcl'}));
}
S.tfCode=S.tfFiles.map(f=>'// filename: '+f.name+'\n'+f.content).join('\n\n');
}