repo-knowledge-graph 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/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nithin Naubad
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,13 @@
1
- # Temporary Holding Version
1
+ # repo-knowledge-graph
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Early alpha CLI and npm library. Node.js 20+. **Not published to npm.**
4
+
5
+ ```sh
6
+ npm install github:naubadnithin7/repo-knowledge-graph
7
+ repo-graph index
8
+ repo-graph search authentication
9
+ repo-graph context authentication
10
+ repo-graph savings authentication
11
+ ```
12
+
13
+ Incremental symbol parsing reuses unchanged file metadata; files are still read and hashed. Optional TypeScript AST extraction requires `typescript` and `REPO_GRAPH_PARSER=typescript`. Graphify-compatible `toGraphify(graph,{createGraph})` requires a graph object exposing `addNode`/`addEdge`; integration with a specific Graphify release is not verified. Laya-compatible local decision adapter is available but no model is bundled. Token savings are estimates, not billing measurements. See SECURITY.md.
package/SECURITY.md ADDED
@@ -0,0 +1,3 @@
1
+ # Security review (alpha)
2
+
3
+ The scanner does not execute source or persist source content. It excludes common secret filenames and recognizable credential patterns; this is not comprehensive secret detection. Context reads reject symlinks and path escapes. External embedding/model adapters may transmit source. Known limitations include partial gitignore support, TOCTOU race, incomplete secret detection, and no independent penetration test. Never commit `.repo-graph/`.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import {scan,save,load,searchFiles,context} from '../src/index.js';
3
+ const [cmd,...args]=process.argv.slice(2),root=process.cwd();
4
+ try{if(cmd==='index'){let previous;try{previous=await load(root);}catch{}const g=await scan(root,{previous,parser:process.env.REPO_GRAPH_PARSER||'regex'});console.log(JSON.stringify({file:await save(g),files:g.files.length,nodes:g.nodes.length,edges:g.edges.length,reusedFiles:g.reusedFiles},null,2));}else if(['stats','search','context','savings'].includes(cmd)){const g=await load(root),query=args.join(' ');if(cmd==='stats')console.log(JSON.stringify({files:g.files.length,nodes:g.nodes.length,edges:g.edges.length}));else{if(!query)throw Error('Query required');const r=cmd==='search'?await searchFiles(g,query):await context(g,query);console.log(JSON.stringify(cmd==='savings'?{estimatedTokensSaved:r.estimatedTokensSaved,estimatedSavingsPercent:r.estimatedSavingsPercent,method:r.method}:r,null,2));}}else{console.log('Usage: repo-graph <index|stats|search|context|savings> [query]');if(cmd)process.exitCode=1;}}catch(e){console.error(e.message);process.exitCode=1;}
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "repo-knowledge-graph",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.5.0",
4
+ "type": "module",
5
+ "description": "Repository knowledge graph and CLI",
6
+ "bin": {
7
+ "repo-graph": "bin/repo-graph.js"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.js",
11
+ "./adapters": "./src/adapters.js"
12
+ },
13
+ "scripts": {
14
+ "test": "node --test",
15
+ "check": "node --check src/index.js && node --check src/adapters.js && node --check bin/repo-graph.js",
16
+ "prepublishOnly": "npm test && npm run check"
17
+ },
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "files": [
22
+ "src",
23
+ "bin",
24
+ "README.md",
25
+ "LICENSE",
26
+ "SECURITY.md"
27
+ ],
28
+ "license": "MIT",
29
+ "peerDependencies": {
30
+ "typescript": "^5.0.0"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "typescript": {
34
+ "optional": true
35
+ }
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/naubadnithin7/repo-knowledge-graph.git"
40
+ }
41
+ }
@@ -0,0 +1,5 @@
1
+ export async function extractTypeScript(source,filename='source.ts'){let ts;try{ts=await import('typescript');}catch{throw Error('Install typescript to use AST parsing');}const ast=ts.createSourceFile(filename,source,ts.ScriptTarget.Latest,true),symbols=[],imports=[];function visit(n){if(ts.isImportDeclaration(n)&&ts.isStringLiteral(n.moduleSpecifier))imports.push(n.moduleSpecifier.text);if((ts.isFunctionDeclaration(n)||ts.isClassDeclaration(n)||ts.isInterfaceDeclaration(n)||ts.isTypeAliasDeclaration(n))&&n.name)symbols.push({name:n.name.text,line:ast.getLineAndCharacterOfPosition(n.getStart(ast)).line+1});ts.forEachChild(n,visit);}visit(ast);return {symbols,imports};}
2
+ export function createDecisionAdapter({endpoint,model='laya',fetchImpl=fetch,allowedHosts=['localhost','127.0.0.1','::1']}={}){const url=new URL(endpoint);if(!['http:','https:'].includes(url.protocol)||!allowedHosts.includes(url.hostname))throw Error('Endpoint not allowlisted');return async input=>{const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),10000);try{const response=await fetchImpl(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({model,messages:[{role:'system',content:'Return JSON {"kind":"model"|"agent","name":"target"}'},{role:'user',content:String(input).slice(0,4000)}],temperature:0}),signal:controller.signal});if(!response.ok)throw Error('Decision HTTP '+response.status);const content=(await response.json())?.choices?.[0]?.message?.content;if(typeof content!=='string'||content.length>2000)throw Error('Invalid decision');const d=JSON.parse(content);if(!['model','agent'].includes(d.kind)||typeof d.name!=='string'||!/^[-\w.]{1,80}$/.test(d.name))throw Error('Invalid route');return d;}finally{clearTimeout(timer);}};}
3
+ export function toGraphify(graph,{createGraph,graphInstance}={}){const target=graphInstance??createGraph?.();if(!target||typeof target.addNode!=='function'||typeof target.addEdge!=='function')throw Error('Graphify adapter requires addNode/addEdge');for(const n of graph.nodes)target.addNode(n.id,{...n});for(const e of graph.edges)target.addEdge(e.from,e.to,{type:e.type});return target;}
4
+ export function toGraphData(graph){return {nodes:graph.nodes.map(({id,...attributes})=>({id,attributes})),edges:graph.edges.map(({from,to,...attributes})=>({source:from,target:to,attributes}))};}
5
+ export async function transformGraph(graph,{transform}={}){if(typeof transform!=='function')throw Error('Provide transform function');return transform(graph);}
@@ -0,0 +1,14 @@
1
+ import {performance} from 'node:perf_hooks';
2
+ /** Retrieval quality benchmark with optional model tokenizer. */
3
+ export async function benchmark(graph,cases,{retrieve,tokenizer,limit=5}={}){
4
+ if(!Array.isArray(cases)||!cases.length||typeof retrieve!=='function')throw Error('Provide cases and retrieve function');
5
+ const count=tokenizer||((s)=>Math.ceil(String(s).length/4)),results=[];
6
+ for(const entry of cases){if(typeof entry.query!=='string'||!Array.isArray(entry.relevantPaths))throw Error('Invalid benchmark case');
7
+ const start=performance.now(),r=await retrieve(graph,entry.query,{limit}),latencyMs=performance.now()-start;
8
+ const selected=Array.isArray(r)?r:r?.selected;if(!Array.isArray(selected))throw Error('Invalid retrieval result');
9
+ const relevant=new Set(entry.relevantPaths),hits=selected.filter(f=>relevant.has(f.path)).length;
10
+ results.push({query:entry.query,precisionAtK:selected.length?hits/selected.length:0,recallAtK:relevant.size?hits/relevant.size:1,selectedTokens:selected.reduce((n,f)=>n+count(f.content||''),0),latencyMs,selectedPaths:selected.map(f=>f.path)});
11
+ }
12
+ const mean=k=>results.reduce((n,r)=>n+r[k],0)/results.length;
13
+ return {cases:results.length,meanPrecisionAtK:mean('precisionAtK'),meanRecallAtK:mean('recallAtK'),meanSelectedTokens:mean('selectedTokens'),meanLatencyMs:mean('latencyMs'),tokenCounting:tokenizer?'supplied tokenizer':'characters/4 estimate',results};
14
+ }
package/src/index.js ADDED
@@ -0,0 +1,36 @@
1
+ import {readdir,readFile,mkdir,writeFile,lstat,realpath} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {createHash} from 'node:crypto';
4
+ import {extractTypeScript} from './adapters.js';
5
+ const skip=new Set(['.git','node_modules','dist','build','.repo-graph','coverage','.venv','vendor']);
6
+ const ext=new Set(['.js','.jsx','.ts','.tsx','.mjs','.cjs','.py','.go','.java','.rs','.md','.json']);
7
+ const sensitive=/(^|\/)(\.env(?:\..*)?|id_rsa|id_ed25519|[^/]*\.(?:pem|key|p12)|credentials(?:\..*)?|secrets?(?:\..*)?)$/i;
8
+ const norm=p=>p.split(path.sep).join('/');
9
+ const secretPattern=/(?:-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|(?:ghp_|github_pat_|sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}))/;
10
+ export const estimateTokens=s=>Math.ceil(String(s).length/4);
11
+ async function patterns(root){try{return (await readFile(path.join(root,'.gitignore'),'utf8')).split(/\r?\n/).filter(x=>x&&!x.startsWith('#'));}catch{return [];}}
12
+ function ignored(rel,ps){return ps.some(p=>{p=p.replace(/^\//,'');if(p.endsWith('/'))return rel.startsWith(p)||rel.includes('/'+p);return rel===p||rel.endsWith('/'+p)||(p.startsWith('*.')&&rel.endsWith(p.slice(1)));});}
13
+ function resolve(spec,rel,known){if(!spec.startsWith('.'))return 'module:'+spec;const base=norm(path.posix.normalize(path.posix.join(path.posix.dirname(rel),spec)));return 'file:'+([base,base+'.js',base+'.ts',base+'.tsx',base+'.jsx',base+'/index.js',base+'/index.ts'].find(p=>known.has(p))||'__missing__')==='file:__missing__'?'module:'+spec:'file:'+([base,base+'.js',base+'.ts',base+'.tsx',base+'.jsx',base+'/index.js',base+'/index.ts'].find(p=>known.has(p)));}
14
+ export async function scan(root,{maxBytes=256000,previous=null,parser='regex'}={}){
15
+ if(!['regex','typescript'].includes(parser))throw Error('Unknown parser');
16
+ root=path.resolve(root);const ps=await patterns(root),files=[],nodes=[],edges=[],contents=new Map(),prior=new Map((previous?.files||[]).map(f=>[f.path,f]));let reusedFiles=0;
17
+ async function walk(dir){for(const ent of await readdir(dir,{withFileTypes:true})){if(ent.isSymbolicLink()||skip.has(ent.name))continue;const full=path.join(dir,ent.name),rel=norm(path.relative(root,full));if(ignored(rel,ps)||sensitive.test(rel))continue;if(ent.isDirectory()){await walk(full);continue;}if(!ent.isFile()||!ext.has(path.extname(ent.name)))continue;let content;try{content=await readFile(full,'utf8');}catch{continue;}const bytes=Buffer.byteLength(content);if(bytes>maxBytes||content.includes('\0')||secretPattern.test(content))continue;const hash=createHash('sha256').update(content).digest('hex');if(prior.get(rel)?.hash===hash)reusedFiles++;files.push({path:rel,hash,bytes});contents.set(rel,content);}}
18
+ await walk(root);files.sort((a,b)=>a.path.localeCompare(b.path));const known=new Set(files.map(f=>f.path));
19
+ for(const f of files){const content=contents.get(f.path),id='file:'+f.path;nodes.push({id,type:'file',name:f.path,hash:f.hash});let symbols=[],imports=[];const old=prior.get(f.path);
20
+ if(old?.hash===f.hash&&previous?.parser===parser&&Array.isArray(old.symbols)&&Array.isArray(old.imports)){symbols=old.symbols;imports=old.imports;}
21
+ else if(parser==='typescript'&&/\.[cm]?[jt]sx?$/.test(f.path)){const ast=await extractTypeScript(content,f.path);symbols=ast.symbols;imports=ast.imports;}
22
+ else{symbols=[...content.matchAll(/\b(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|def)\s+([A-Za-z_$][\w$]*)/g)].map(m=>({name:m[1]}));imports=[...content.matchAll(/(?:import\s+(?:[^'"]+?\s+from\s+)?|require\s*\(|from\s+)['"]([^'"]+)['"]/g)].map(m=>m[1]);}
23
+ f.symbols=symbols;f.imports=imports;
24
+ for(const s of symbols){const sid='symbol:'+f.path+':'+s.name;nodes.push({id:sid,type:'symbol',name:s.name,file:f.path,...(s.line?{line:s.line}:{})});edges.push({from:id,to:sid,type:'contains'});}
25
+ for(const spec of imports)edges.push({from:id,to:resolve(spec,f.path,known),type:'imports'});
26
+ }
27
+ for(const e of edges.filter(e=>e.to.startsWith('module:')))if(!nodes.some(n=>n.id===e.to))nodes.push({id:e.to,type:'module',name:e.to.slice(7)});
28
+ return {schemaVersion:4,root,generatedAt:new Date().toISOString(),nodes,edges,files,reusedFiles,parser};
29
+ }
30
+ export async function readContextFile(graph,rel){const root=path.resolve(graph.root),file=path.resolve(root,rel);if(!file.startsWith(root+path.sep)||!graph.files.some(f=>f.path===rel))throw Error('Unsafe or unindexed file');const info=await lstat(file);if(!info.isFile()||info.isSymbolicLink())throw Error('Unsafe file type');const actual=await realpath(file),realRoot=await realpath(root);if(!actual.startsWith(realRoot+path.sep))throw Error('File escaped repository');const content=await readFile(actual,'utf8');if(secretPattern.test(content))throw Error('Potential secret detected');return content;}
31
+ export async function searchFiles(graph,query,{limit=10}={}){const terms=String(query).toLowerCase().split(/\s+/).filter(Boolean);if(!terms.length)return [];const found=[];for(const f of graph.files){const body=(await readContextFile(graph,f.path)).toLowerCase(),name=f.path.toLowerCase(),score=terms.reduce((n,t)=>n+(name.includes(t)?5:0)+(body.includes(t)?1:0),0);if(score)found.push({path:f.path,score});}return found.sort((a,b)=>b.score-a.score||a.path.localeCompare(b.path)).slice(0,limit);}
32
+ export async function context(graph,query,{limit=5,maxTokens=4000}={}){let used=0,baseline=0;const selected=[];for(const f of graph.files)baseline+=estimateTokens(await readContextFile(graph,f.path));for(const f of await searchFiles(graph,query,{limit:graph.files.length})){if(selected.length>=limit)break;const content=await readContextFile(graph,f.path),cost=estimateTokens(content);if(used+cost>maxTokens)continue;selected.push({path:f.path,content,estimatedTokens:cost});used+=cost;}return {query,selected,estimatedContextTokens:used,estimatedBaselineTokens:baseline,estimatedTokensSaved:Math.max(0,baseline-used),estimatedSavingsPercent:baseline?Math.round(100*(baseline-used)/baseline):0,method:'characters/4 heuristic'};}
33
+ export async function save(graph,root=graph.root){const dir=path.join(root,'.repo-graph');await mkdir(dir,{recursive:true});const target=path.join(dir,'graph.json');await writeFile(target,JSON.stringify(graph,null,2));return target;}
34
+ export async function load(root){const g=JSON.parse(await readFile(path.join(path.resolve(root),'.repo-graph','graph.json'),'utf8'));if(g.schemaVersion!==4)throw Error('Re-index required');return g;}
35
+ export async function semanticSearch(graph,query,{embed,limit=5}={}){if(typeof embed!=='function')throw Error('Provide embed(text)');const q=await embed(query),norm=v=>Math.sqrt(v.reduce((n,x)=>n+x*x,0)),qn=norm(q),rows=[];for(const f of graph.files){const v=await embed(await readContextFile(graph,f.path));if(v.length!==q.length)throw Error('Embedding dimension mismatch');const vn=norm(v);rows.push({path:f.path,score:qn&&vn?v.reduce((n,x,i)=>n+x*q[i],0)/(qn*vn):0});}return rows.sort((a,b)=>b.score-a.score).slice(0,limit);}
36
+ export async function routeTask(input,{decide,models={},agents={},fallback='default'}={}){if(typeof decide!=='function')throw Error('Provide decide(input)');const decision=await decide(input),kind=decision?.kind==='agent'?'agent':'model',name=decision?.name||fallback,registry=kind==='agent'?agents:models;if(!Object.hasOwn(registry,name))throw Error('Unregistered '+kind+': '+name);return {kind,name,decision,execute:(...args)=>registry[name](input,...args)};}