diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eddfe1c..d9808c16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ All notable changes to sem are documented in this file. ### Fixed +- Simple transaction read batches preserve valid results when another selector is malformed or a requested path is rejected as a symlink. Partial captures report their errors explicitly; strict capture and edit checks remain unchanged. + - **Copilot CLI can connect to the MCP server again.** Unsupported discovery probes return `Method not found` without closing the connection, allowing clients to fall back to `initialize` in both standalone and shared modes. Fixes #497. - **Indexed name lookup sees renames and added definitions in edited files.** `sem find` checks indexed file freshness and reparses changed files on demand, without requiring a whole dependency-graph refresh. Includes TypeScript, Python and Rust regression coverage. - **Dart dependency graphs now resolve ordinary calls, constructor-bound receivers and typed parameters.** Callers and refs no longer select a same-named Dart method for a TypeScript receiver (or vice versa); imported class owners take precedence. Persisted graph/query caches are invalidated so upgrades rebuild the affected edges. Fixes #491. diff --git a/pi/src/transaction/simple/exact-code.mjs b/pi/src/transaction/simple/exact-code.mjs index 7fc295b6..272b022b 100644 --- a/pi/src/transaction/simple/exact-code.mjs +++ b/pi/src/transaction/simple/exact-code.mjs @@ -38,11 +38,12 @@ export class ExactCode { if(!Number.isSafeInteger(maxSnapshots)||maxSnapshots<1) fail('INVALID_SNAPSHOT_CAPACITY'); Object.assign(this,{semBin,maxBytes,maxSnapshots}); this.snapshots=new Map(); } - async capture(cwd, files, {allowMissing=false}={}) { + async capture(cwd, files, {allowMissing=false,partialReads=false}={}) { if(!Array.isArray(files)||!files.length||files.length>64) fail('INVALID_FILE_SCOPE'); const root=await fs.realpath(cwd), sources=new Map(); let total=0; const missing=[]; + const fileErrors=[]; for(const file of [...new Set(files)].sort(order)) { if(typeof file!=='string'||!file||path.isAbsolute(file)||file.split('/').some(x=>!x||x==='.'||x==='..')) fail('INVALID_PATH'); const absolute=path.join(root,file); @@ -52,6 +53,9 @@ export class ExactCode { stat=await fs.stat(absolute); } catch(error) { if(allowMissing&&error.code==='ENOENT') {missing.push(file);continue;} + if(partialReads&&error.message==='SYMLINK_NOT_SUPPORTED') { + fileErrors.push({file,code:'SYMLINK_NOT_SUPPORTED'});continue; + } throw error; } if(!stat.isFile()||stat.size>this.maxBytes-total) fail('SCOPE_TOO_LARGE'); @@ -61,7 +65,7 @@ export class ExactCode { sources.set(file,bytes); } const manifest=[...sources].map(([file,b])=>({file,sha256:hash(b)})); - const revision=hash(JSON.stringify([manifest,missing])); + const revision=hash(JSON.stringify(fileErrors.length?[manifest,missing,fileErrors]:[manifest,missing])); if(!this.snapshots.has(revision)) { const tmp=await fs.mkdtemp(path.join(os.tmpdir(),'sem-exact-')); const entities=[]; @@ -83,13 +87,13 @@ export class ExactCode { entities.sort((a,b)=>order(a.file,b.file)||a.start-b.start||a.end-b.end||order(a.id,b.id)); // Lexical containment only, not receiver/type or runtime resolution. qualifyEntities(entities); - this.snapshots.set(revision,{sources,entities,manifest,...indexEntities(entities)}); + this.snapshots.set(revision,{sources,entities,manifest,fileErrors,...indexEntities(entities)}); // Evict only after a successful capture. Failed parsing must not destroy // usable snapshots. IDs remain revision-bound, never redirected. while(this.snapshots.size>this.maxSnapshots) this.snapshots.delete(this.snapshots.keys().next().value); } this.get(revision); - return {revision,files:manifest,missing_files:missing,scope:'explicit_files',coverage:'parser_reported_only',consistency:'captured_file_bytes_not_atomic_repository_snapshot'}; + return {revision,files:manifest,missing_files:missing,...(fileErrors.length?{file_errors:fileErrors,complete:false}:{}),scope:'explicit_files',coverage:'parser_reported_only',consistency:'captured_file_bytes_not_atomic_repository_snapshot'}; } get(revision) { const snapshot=this.snapshots.get(revision); @@ -116,6 +120,8 @@ export class ExactCode { const s=this.get(revision), sources=new Map(), files=new Map(); let fileBudget=48000; const results=selectors.map(selector=>{ + const unavailable=s.fileErrors?.find(error=>error.file===selector?.file); + if(unavailable) return {selector,status:'error',error:unavailable,complete:false}; if(selector && typeof selector==='object' && Object.keys(selector).length===1 && typeof selector.file==='string' && selector.file) { const bytes=s.sources.get(selector.file); if(!bytes) return {selector,status:'not_found'}; @@ -129,7 +135,7 @@ export class ExactCode { if(!selector||typeof selector!=='object'||Array.isArray(selector)|| Object.keys(selector).some(k=>!['id','name','file','type'].includes(k))|| (typeof selector.id==='string')===(typeof selector.name==='string')|| - Object.values(selector).some(v=>typeof v!=='string'||!v)) fail('INVALID_SELECTOR'); + Object.values(selector).some(v=>typeof v!=='string'||!v)) return {selector,status:'error',error:{code:'INVALID_SELECTOR'},complete:false}; const matches=lookupEntities(s,selector); const status=matches.length===0?'not_found':matches.length===1?'unique':'ambiguous'; if(status==='unique') { @@ -144,7 +150,7 @@ export class ExactCode { }); // Reuse containing source only within this response. No assumption that a // previous tool response is still in the model's context. - return {revision,results,sources:compactSources([...sources.values()]),...(files.size?{files:[...files.values()]}:{}),coverage:'parser_reported_only'}; + return {revision,results,...(s.fileErrors?.length?{file_errors:s.fileErrors,complete:false}:{}),sources:compactSources([...sources.values()]),...(files.size?{files:[...files.values()]}:{}),coverage:'parser_reported_only'}; } prepare(revision,edits) { if(!Array.isArray(edits)||!edits.length||edits.length>64) fail('INVALID_EDITS'); diff --git a/pi/src/transaction/simple/exact-code.test.mjs b/pi/src/transaction/simple/exact-code.test.mjs index 8a2be526..4d506def 100644 --- a/pi/src/transaction/simple/exact-code.test.mjs +++ b/pi/src/transaction/simple/exact-code.test.mjs @@ -85,8 +85,8 @@ test('exact snapshot contract against real SEM parser',async()=>{ assert.equal(api.query(s.revision,[{file:'absent.ts'}]).results[0].status,'not_found'); assert.deepEqual({revision:s.revision,...batch.sources[0]},read); assert.throws(()=>api.query(s.revision,[]),/INVALID_SELECTORS/); - assert.throws(()=>api.query(s.revision,[{name:'same',id:e.id}]),/INVALID_SELECTOR/); - assert.throws(()=>api.query(s.revision,[{name:'same',file:42}]),/INVALID_SELECTOR/); + assert.equal(api.query(s.revision,[{name:'same',id:e.id}]).results[0].error.code,'INVALID_SELECTOR'); + assert.equal(api.query(s.revision,[{name:'same',file:42}]).results[0].error.code,'INVALID_SELECTOR'); assert.equal(read.content,Buffer.from(source).subarray(e.start,e.end).toString()); const replacement='export function same() { return 42; }'; const prepared=api.prepare(s.revision,[{id:e.id,content:replacement}]); diff --git a/pi/src/transaction/simple/partial-read.test.mjs b/pi/src/transaction/simple/partial-read.test.mjs new file mode 100644 index 00000000..5984f501 --- /dev/null +++ b/pi/src/transaction/simple/partial-read.test.mjs @@ -0,0 +1,33 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {ExactCode} from './exact-code.mjs'; + +test('read batches preserve valid siblings without following symlinks or weakening capture',async()=>{ + const root=await fs.mkdtemp(path.join(os.tmpdir(),'sem-partial-read-')); + try { + const content='def value():\n return 1\n'; + await fs.writeFile(path.join(root,'good.py'),content); + await fs.symlink('good.py',path.join(root,'alias.py')); + const exact=new ExactCode({semBin:process.env.SEM_TEST_BIN||'sem'}); + const captured=await exact.capture(root,['good.py','alias.py'],{partialReads:true}); + assert.equal(captured.complete,false); + const selectors=[{file:'alias.py'},{file:'good.py'},null,{name:'value'}]; + const result=exact.query(captured.revision,selectors); + assert.deepEqual(result.results.map(r=>r.status),['error','unique','error','unique']); + assert.equal(result.results[0].error.code,'SYMLINK_NOT_SUPPORTED'); + assert.equal(result.results[2].error.code,'INVALID_SELECTOR'); + assert.equal(result.files[0].content,content); + assert.equal(result.sources[0].entity.name,'value'); + assert.equal(result.complete,false); + assert.equal(result.file_errors.length,1); + assert.deepEqual(exact.query(captured.revision,selectors),result); + await assert.rejects(exact.capture(root,['good.py','alias.py']),/SYMLINK_NOT_SUPPORTED/); + await assert.rejects(exact.capture(root,['../outside'],{partialReads:true}),/INVALID_PATH/); + const complete=await exact.capture(root,['good.py']); + assert.notEqual(complete.revision,captured.revision); + assert.throws(()=>exact.query(complete.revision,[]),/INVALID_SELECTORS/); + } finally {await fs.rm(root,{recursive:true,force:true});} +}); diff --git a/pi/src/transaction/simple/sem-session-simple-mcp.mjs b/pi/src/transaction/simple/sem-session-simple-mcp.mjs index f745222e..0cb04647 100644 --- a/pi/src/transaction/simple/sem-session-simple-mcp.mjs +++ b/pi/src/transaction/simple/sem-session-simple-mcp.mjs @@ -882,7 +882,7 @@ if(process.env.SEM_EXACT_TOOLS === '1') { } case 'query': { if(Boolean(p.revision)===Boolean(p.files)) throw new Error('PROVIDE_REVISION_OR_FILES'); - const snapshot=p.files?await exact.capture(cwd,p.files,{allowMissing:true}):null; + const snapshot=p.files?await exact.capture(cwd,p.files,{allowMissing:true,partialReads:true}):null; return {...exact.query(snapshot?.revision??p.revision,p.selectors),...(snapshot?{snapshot}: {})}; } case 'capture': return exact.capture(cwd,p.files);