Files
jobtrackingapp/docs/redesign/audit/generate-inventory.cjs
T
2026-09-09 19:29:39 +02:00

77 lines
7.1 KiB
JavaScript

// Read-only source census for the Phase 1 audit. Uses the repository's installed TypeScript parser.
const fs = require('fs');
const path = require('path');
const cp = require('child_process');
const root = process.cwd();
const ts = require(path.join(root, 'job-tracker-ui/node_modules/typescript'));
const out = path.join(root, 'docs/redesign/audit');
fs.mkdirSync(out, {recursive:true});
const paths = cp.execFileSync('git', ['ls-files'], {encoding:'utf8'}).trim().split('\n');
const files = paths.filter(p => /^job-tracker-ui\/(src|app)\/.+\.[jt]sx?$/.test(p) && !/\.test\./.test(p) && !p.endsWith('setupTests.ts'));
const tests = paths.filter(p => /(?:\.test\.[jt]sx?$|\.spec\.tsx?$)/.test(p) && p.startsWith('job-tracker-ui/'));
const sources = {};
const routes = [];
function loc(sf,n) { return sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1; }
function resolve(from, dep) {
if (!dep.startsWith('.')) return null;
const base = path.posix.normalize(path.posix.join(path.posix.dirname(from),dep));
return [base, ...['.ts','.tsx','.js','.jsx','/index.ts','/index.tsx'].map(s=>base+s)].find(p=>files.includes(p)) || null;
}
for (const file of files) {
const content = fs.readFileSync(path.join(root,file),'utf8');
const sf = ts.createSourceFile(file,content,ts.ScriptTarget.Latest,true,file.endsWith('tsx')?ts.ScriptKind.TSX:ts.ScriptKind.TS);
const info = {lines:content.split(/\r?\n/).length, imports:[], api:[], labels:[], components:[], storage:[], functions:[]};
function visit(n) {
if(ts.isImportDeclaration(n) && ts.isStringLiteral(n.moduleSpecifier)) info.imports.push(n.moduleSpecifier.text);
if(ts.isCallExpression(n)) {
if(n.expression.kind===ts.SyntaxKind.ImportKeyword && n.arguments[0] && ts.isStringLiteral(n.arguments[0])) info.imports.push(n.arguments[0].text);
if(ts.isPropertyAccessExpression(n.expression) && /^(api|axios)$/.test(n.expression.expression.getText(sf)) && /^(get|post|put|patch|delete|request)$/.test(n.expression.name.text)) info.api.push({line:loc(sf,n),method:n.expression.name.text.toUpperCase(),url:n.arguments[0]?.getText(sf)||''});
if(n.expression.getText(sf)==='t' && n.arguments[0] && ts.isStringLiteral(n.arguments[0])) info.labels.push(n.arguments[0].text);
if(/(?:localStorage|sessionStorage)\.(?:getItem|setItem|removeItem)$/.test(n.expression.getText(sf))) info.storage.push({line:loc(sf,n),call:n.getText(sf).replace(/\s+/g,' ').slice(0,220)});
}
if((ts.isFunctionDeclaration(n)||ts.isVariableDeclaration(n)) && n.name && /^[A-Z]/.test(n.name.getText(sf))) info.functions.push({name:n.name.getText(sf),line:loc(sf,n)});
if(ts.isJsxOpeningElement(n)||ts.isJsxSelfClosingElement(n)) {
const tag=n.tagName.getText(sf);
if(/^[A-Z]/.test(tag)) info.components.push(tag);
if(tag==='Route') {
const attrs=Object.fromEntries(n.attributes.properties.filter(ts.isJsxAttribute).map(a=>[a.name.getText(sf),a.initializer?.getText(sf)||'']));
routes.push({file,line:loc(sf,n),...attrs});
}
}
if(file.endsWith('/App.tsx') && ts.isPropertyAssignment(n) && n.name.getText(sf)==='path' && ts.isStringLiteral(n.initializer)) routes.push({file,line:loc(sf,n),path:n.initializer.text,element:n.parent.getText(sf).replace(/\s+/g,' ')});
ts.forEachChild(n,visit);
}
visit(sf);
for(const key of ['imports','labels','components']) info[key]=[...new Set(info[key])].sort();
info.dependencies=info.imports.map(i=>resolve(file,i)).filter(Boolean);
sources[file]=info;
}
const reachable = new Set();
function walk(file) { if(reachable.has(file)||!sources[file])return; reachable.add(file); sources[file].dependencies.forEach(walk); }
walk('job-tracker-ui/src/ClientApp.tsx');
for (const [file,info] of Object.entries(sources)) {
info.reachableFromClientApp=reachable.has(file);
info.importedBy=Object.keys(sources).filter(f=>sources[f].dependencies.includes(file));
}
const summary = {baseline:cp.execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim(),sourceFiles:files.length,frontendTestFiles:tests.length,apiCallSites:Object.values(sources).reduce((n,s)=>n+s.api.length,0),routeDeclarations:routes.length};
fs.writeFileSync(path.join(out,'source-inventory.json'),JSON.stringify({summary,routes,tests,sources},null,2)+'\n');
const cell=s=>String(s).replaceAll('|','\\|').replaceAll('\n',' ');
const link=(file,line)=>`[${file.replace('job-tracker-ui/','')}:${line}](../../../${file}#L${line})`;
// docs/redesign/audit is three directories below the repository root.
const sourceLink=link;
let api='# API call-site inventory\n\nGenerated with the installed TypeScript AST from the committed baseline. Each row is an actual `api`/`axios` call expression, not a claim of runtime test coverage. Paths are relative to the shared `/api` base; interpolations are preserved. Domain wrappers are included. Companion technical audit maps contracts and authorization.\n\n| Source | Method | URL expression |\n|---|---|---|\n';
for(const [file,info] of Object.entries(sources)) for(const call of info.api) api+=`| ${sourceLink(file,call.line)} | ${call.method} | ${cell(call.url)} |\n`;
fs.writeFileSync(path.join(out,'API_CALL_SITES.md'),api);
let components='# Component and source inventory\n\nEvery production TSX module under `src` and `app` is listed. Lines are size indicators, not complexity scores. Reachability is relative-import reachability from ClientApp; app entry files are expected to be outside that graph. MUI primitives are dependencies, not missing local components. No unreachable file is approved for deletion.\n\n| Module | Lines | Client graph | Local direct consumers | Rendered component names |\n|---|---|---|---|---|\n';
for(const [file,info] of Object.entries(sources)) if(file.endsWith('.tsx')) components+=`| ${sourceLink(file,1)} | ${info.lines} | ${info.reachableFromClientApp?'reachable':'outside graph'} | ${cell(info.importedBy.map(f=>path.basename(f)).join(', ')||'—')} | ${cell(info.components.join(', '))} |\n`;
fs.writeFileSync(path.join(out,'COMPONENT_INVENTORY.md'),components);
let testList='# Test file inventory\n\nTracked test files at the audit baseline. Presence is not proof that a feature or every interaction is covered. See TEST_INVENTORY.md for scope and execution evidence.\n\n| Layer | File |\n|---|---|\n';
for(const file of paths.filter(p => tests.includes(p) || /^JobTrackerApi.Tests\/.+\.cs$/.test(p) && /Tests?\.cs$/.test(p) || /^browser-extension\/tests\/.+\.test\.mjs$/.test(p) || /^tools\/summarizer\/tests\/.+\.py$/.test(p) || /^scripts\/test-.+\.py$/.test(p))) {
const layer=file.startsWith('job-tracker-ui/src/')?'Frontend unit/component':file.startsWith('job-tracker-ui/')?'Browser / HTTP':file.startsWith('JobTrackerApi.Tests/')?'Backend':file.startsWith('tools/')?'Parser / AI service':file.startsWith('browser-extension/')?'Browser extension':'Repository safety';
testList+=`| ${layer} | ${sourceLink(file,1)} |\n`;
}
fs.writeFileSync(path.join(out,'TEST_FILES.md'),testList);
console.log(JSON.stringify(summary));
console.log('Largest:',Object.entries(sources).sort((a,b)=>b[1].lines-a[1].lines).slice(0,12).map(([f,s])=>[f,s.lines]));
console.log('Outside graph:',Object.entries(sources).filter(([f,s])=>f.endsWith('.tsx')&&!s.reachableFromClientApp).map(([f])=>f));