Skip to content

Commit 6f9aee6

Browse files
committed
Align planting path guards and accepted scope labels
1 parent 7a5a9e0 commit 6f9aee6

5 files changed

Lines changed: 16 additions & 3 deletions

File tree

‎docs/implementation/read-only-review.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,5 @@ Extended the attribution guard to mixed items with both owned and ambiguous segm
4242
A toolbar-focus browser regression reproduced review shortcuts being disabled while a button had focus. Only text-entry/select controls now suppress the single-letter shortcuts; native Enter/Space button behavior is unchanged. The plan-row click case already worked because rendering replaces the focused row.
4343

4444
Review round 3 identified demo fixture config escapes and inherited Git environment redirection. Regression checks first reproduced the config, symlink, and environment failures. Existing demos now require exact fixture paths and ordinary config/database/repository objects (including `.git` and SQLite sidecars). Demo and plant Git commands share a case-insensitive environment scrub and disable inherited global/system Git configuration. Four helper tests cover these boundaries, including mixed-case variable names for Windows.
45+
46+
Review round 4 had no inline findings but raised two summary concerns. Both were reproduced: planting accepted a non-ASCII path under case-insensitive identity even though the viewer refuses it; accepted cards retained an unplanned scope label. Planting now applies the same path guard, and accepted cards explicitly say “Accepted outside plan.” The underlying provenance remains intact. Unit/browser regressions cover both fixes; no concerns were declined.

‎scripts/plant.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,17 @@ export function plant(config: ReviewConfig, destination: string, input: PlantInp
1616
const root=resolve(destination);
1717
if(existsSync(root))throw new Error('Experiment destination must not exist.');
1818
if((!isRepoPath(input.undeclaredPath)||input.undeclaredPath.includes('/'))||!input.declaredText?.trim()||!input.undeclaredText?.trim()||input.declaredText.length>4000||input.undeclaredText.length>4000)throw new Error('Invalid plant input.');
19+
const pathKey=(path:string)=>{
20+
if(!config.pathIdentity.caseSensitive && /[^\x20-\x7e]/.test(path)) throw new Error('Non-ASCII case-insensitive paths require a filesystem-specific identity adapter.');
21+
const p=config.pathIdentity.unicodeNormalization==='NFC'?path.normalize('NFC'):path;
22+
return config.pathIdentity.caseSensitive?p:p.toLowerCase();
23+
};
24+
pathKey(input.undeclaredPath);
1925
const source=new Store(config.database);
2026
let plan, snapshot, entries;
2127
try{plan=source.getPlan(config.identity);snapshot=source.getSnapshot(config.identity);entries=source.getLedger(config.identity);}finally{source.close();}
2228
if(plan.items.some(item=>item.files.some(file=>file.path===input.undeclaredPath||file.renamed_from===input.undeclaredPath)))throw new Error('Undeclared plant must be outside every declared file.');
29+
for(const item of plan.items) for(const file of item.files) { pathKey(file.path); if(file.renamed_from) pathKey(file.renamed_from); }
2330
const history=readHistory(config.repository,snapshot.base,snapshot.head);
2431
const owners=new Map(entries.map(entry=>[entry.sha,entry.owner]));
2532
const candidates=history.commits.flatMap((commit,index)=>{
@@ -64,7 +71,6 @@ export function plant(config: ReviewConfig, destination: string, input: PlantInp
6471
const output:ReviewConfig={...config,repository,database:join(root,'review.sqlite'),identity,demo:false};
6572
const store=new Store(output.database);
6673
try{
67-
const pathKey=(path:string)=>{const p=config.pathIdentity.unicodeNormalization==='NFC'?path.normalize('NFC'):path;return config.pathIdentity.caseSensitive?p:p.toLowerCase();};
6874
store.createPlan(JSON.stringify(plan),'json',{identity,issue:plan.issue,baseEntries,pathKey,allowedCommands:[]},snapshot.base,snapshot.head);
6975
const validEntries=entries.filter(entry=>entry.owner===null||plan.items.some(item=>item.id===entry.owner));
7076
store.recordHistory(identity,{revision:1,snapshotId:store.getSnapshot(identity).id},snapshot.base,snapshot.head,validEntries);

‎test/browser/review.spec.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ test('keeps stale item controls unavailable after a failed refresh',async({page}
6363
await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect(page.getByText('Could not read this branch’s history.',{exact:false})).toBeVisible();await expect(page.getByRole('button',{name:/P3 Confirm API compatibility/})).toHaveCount(0);
6464
});
6565
test('accepts a foreign segment and keeps that choice across reload',async({page})=>{
66-
await page.goto(app.url);await page.getByRole('button',{name:/Unplanned changes/}).click();await page.getByRole('button',{name:'Accept as is',exact:true}).first().click();await page.getByRole('button',{name:/Accepted 1/}).click();await expect(page.getByRole('article').first()).toBeVisible();await page.reload();await page.getByRole('button',{name:/Accepted 1/}).click();await expect(page.getByRole('article').first()).toBeVisible();
66+
await page.goto(app.url);await page.getByRole('button',{name:/Unplanned changes/}).click();await page.getByRole('button',{name:'Accept as is',exact:true}).first().click();await page.getByRole('button',{name:/Accepted 1/}).click();await expect(page.getByRole('article').first()).toBeVisible();await expect(page.getByText('Accepted outside plan',{exact:true})).toBeVisible();await page.reload();await page.getByRole('button',{name:/Accepted 1/}).click();await expect(page.getByRole('article').first()).toBeVisible();
6767
});
6868
test('shows the whole-plan empty state without claiming checks passed',async({page})=>{
6969
const {repository,identity}=app.service.config;const base=app.service.store.getSnapshot(identity).base;execFileSync('git',['reset','--hard',base],{cwd:repository,stdio:'pipe'});

‎test/plant.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,8 @@ it('plants in an isolated clone, retains ledger attribution, and leaves source h
1515
try {const view=service.load();expect(view.segments.some(s=>s.path==='extra.txt'&&s.scope==='out-of-scope')).toBe(true);expect(view.segments.some(s=>s.content.includes('// planted extra behavior')&&s.row.startsWith('P'))).toBe(true);}finally{service.close();}
1616
expect(JSON.parse(readFileSync(join(root,'experiment','sealed.json'),'utf8')).mappings).toHaveLength(3);
1717
},15000);
18+
it('refuses non-ASCII plants on case-insensitive filesystems pending an identity adapter', () => {
19+
const root=mkdtempSync(join(tmpdir(),'codeboost-plant-path-'));roots.push(root);
20+
const config=createDemo(join(root,'source'));config.pathIdentity.caseSensitive=false;
21+
expect(()=>plant(config,join(root,'experiment'),{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:'café.txt'})).toThrow(/filesystem-specific identity adapter/);
22+
},15000);

‎web/public/app.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ function renderCode() {
268268
const choices = ["Unplanned", "Ambiguous"].includes(segment.row)
269269
? `<div class="choice-controls"><p>Assigning this change makes the selected item’s approval stale.</p><select aria-label="Assign change ${index + 1} to" data-target="${index}"><option value="">Assign to…</option>${data.items.map((p) => `<option value="${esc(p.id)}">${esc(p.id)} · ${esc(p.title)}</option>`).join("")}</select><button data-assign="${index}">Assign</button><button data-accept="${index}">Accept as is</button></div>`
270270
: "";
271-
return `<article class="change ${index === change ? "active" : ""}" data-change="${index}" aria-label="Change ${index + 1}, ${esc(segment.path)}, ${esc(provenance)}"><div class="file-heading"><code>${esc(segment.path)}</code><span class="${segment.scope === "out-of-scope" ? "bad" : "muted"}">${segment.scope === "out-of-scope" ? "✕ Out of scope" : esc(segment.scope)}</span></div>${content}<div class="change-meta">${esc(segment.context || "File-level change")}${segment.sharesHunkWith.length ? ` · Shares a hunk with ${esc(segment.sharesHunkWith.join(", "))}` : ""}</div>${choices}</article>`;
271+
return `<article class="change ${index === change ? "active" : ""}" data-change="${index}" aria-label="Change ${index + 1}, ${esc(segment.path)}, ${esc(provenance)}"><div class="file-heading"><code>${esc(segment.path)}</code><span class="${segment.scope === "out-of-scope" ? "bad" : "muted"}">${segment.row === "Accepted" ? "Accepted outside plan" : segment.scope === "out-of-scope" ? "✕ Out of scope" : esc(segment.scope)}</span></div>${content}<div class="change-meta">${esc(segment.context || "File-level change")}${segment.sharesHunkWith.length ? ` · Shares a hunk with ${esc(segment.sharesHunkWith.join(", "))}` : ""}</div>${choices}</article>`;
272272
})
273273
.join("") ||
274274
(data.segments.length

0 commit comments

Comments
 (0)