Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 12 additions & 6 deletions pi/src/transaction/simple/exact-code.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');
Expand All @@ -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=[];
Expand All @@ -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);
Expand All @@ -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'};
Expand All @@ -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') {
Expand All @@ -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');
Expand Down
4 changes: 2 additions & 2 deletions pi/src/transaction/simple/exact-code.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}]);
Expand Down
33 changes: 33 additions & 0 deletions pi/src/transaction/simple/partial-read.test.mjs
Original file line number Diff line number Diff line change
@@ -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});}
});
2 changes: 1 addition & 1 deletion pi/src/transaction/simple/sem-session-simple-mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading