leerness 1.36.297 → 1.36.299

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +4 -4
  3. package/bin/leerness.js +50 -14
  4. package/lib/copy-tree.js +5 -1
  5. package/lib/memory-footprint-capture.js +9 -2
  6. package/lib/runtime-writes.js +24 -4
  7. package/package.json +8 -5
  8. package/scripts/bugfix-areas-probe.js +1 -1
  9. package/scripts/copy-tree-probe.js +17 -0
  10. package/scripts/done-status-probe.js +1 -1
  11. package/scripts/e2e-lived-in-harness.js +2 -2
  12. package/scripts/fix-evidence-language-probe.js +1 -1
  13. package/scripts/i18n-next-cluster-probe.js +1 -1
  14. package/scripts/memory-archive-check-probe.js +1 -1
  15. package/scripts/memory-archive-retention-probe.js +2 -2
  16. package/scripts/memory-arena-cleanup-probe.js +106 -0
  17. package/scripts/memory-checked-input-probe.js +1 -1
  18. package/scripts/memory-guard-order-probe.js +1 -1
  19. package/scripts/memory-input-preflight-probe.js +1 -1
  20. package/scripts/memory-legacy-retention-probe.js +2 -2
  21. package/scripts/memory-migration-test-support.js +40 -2
  22. package/scripts/memory-output-publication-probe.js +1 -1
  23. package/scripts/memory-policy-root-spelling-probe.js +110 -0
  24. package/scripts/memory-projection-retention-probe.js +1 -1
  25. package/scripts/memory-recovery-external-probe.js +68 -1
  26. package/scripts/memory-recovery-receipt-probe.js +1 -1
  27. package/scripts/memory-restore-canonical-probe.js +1 -1
  28. package/scripts/memory-utf8-admission-probe.js +1 -1
  29. package/scripts/path-setup-powershell-probe.js +260 -0
  30. package/scripts/release-bump-probe.js +1 -1
  31. package/scripts/review-request-labels-probe.js +1 -1
  32. package/scripts/review-request-signals-probe.js +1 -1
  33. package/scripts/skill-archive-probe.js +71 -0
  34. package/scripts/store-values-probe.js +9 -4
  35. package/scripts/task-sync-roundtrip-probe.js +1 -1
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+ // T-0354: scripts/memory-migration-test-support.js removes its arena at finish() when every test passed, and keeps
3
+ // it when one failed. A link inside the arena that points outside it is removed as a link: its target and the files
4
+ // in it must survive. An arena, or a folder in it, that was replaced with a link is not walked, whether that happened
5
+ // before finish() or while it ran. Each case runs the support in a child process, the way the memory probes use it.
6
+ const assert = require('assert');
7
+ const cp = require('child_process');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+
12
+ const support = path.join(__dirname, 'memory-migration-test-support.js');
13
+ let passed = 0, failed = 0;
14
+ function test(name, fn) {
15
+ try { fn(); passed++; console.log('PASS ' + name); }
16
+ catch (error) { failed++; console.error('FAIL ' + name + ': ' + (error && error.stack || error)); }
17
+ }
18
+ const linkType = process.platform === 'win32' ? 'junction' : 'dir';
19
+ const unlinkLink = link => { try { fs.unlinkSync(link); } catch { fs.rmdirSync(link); } };
20
+ const outside = fs.mkdtempSync(path.join(fs.realpathSync.native(os.tmpdir()), 'leerness-arena-cleanup-outside-'));
21
+ const sentinel = path.join(outside, 'sentinel.txt');
22
+ fs.writeFileSync(sentinel, 'kept');
23
+ // A second outside folder with a link of its own, for the replacement cases.
24
+ const elsewhere = fs.mkdtempSync(path.join(fs.realpathSync.native(os.tmpdir()), 'leerness-arena-cleanup-elsewhere-'));
25
+ fs.writeFileSync(path.join(elsewhere, 'keep.txt'), 'kept');
26
+ fs.symlinkSync(outside, path.join(elsewhere, 'keep-link'), linkType);
27
+ // The child links to `outside` from inside its arena, runs one test that passes or fails, calls finish(), and prints
28
+ // its arena path. `replace` renames a folder aside and puts a link to `elsewhere` in its place:
29
+ // 'arena-before' the arena, before finish();
30
+ // 'arena-after-check' the arena, right after finish() checked its identity;
31
+ // 'nested-during' the arena's `nested` folder, right after finish() saw it as a folder.
32
+ const child = (shouldFail, replace = '') => cp.spawnSync(process.execPath, ['-e', `
33
+ const S = require(${JSON.stringify(support)});
34
+ const fs = require('fs'), path = require('path');
35
+ const type = ${JSON.stringify(linkType)}, elsewhere = ${JSON.stringify(elsewhere)}, replace = ${JSON.stringify(replace)};
36
+ const swap = dir => { fs.renameSync(dir, dir + '-aside'); fs.symlinkSync(elsewhere, dir, type); };
37
+ fs.mkdirSync(path.join(S.arena, 'nested'));
38
+ fs.symlinkSync(${JSON.stringify(outside)}, path.join(S.arena, 'nested', 'link-out'), type);
39
+ if (replace === 'arena-before') swap(S.arena);
40
+ S.test('child test', () => { if (${shouldFail}) throw new Error('planned failure'); });
41
+ const lstat = fs.lstatSync;
42
+ let armed = replace === 'arena-after-check' || replace === 'nested-during';
43
+ fs.lstatSync = function (file, ...rest) {
44
+ const st = lstat.call(this, file, ...rest);
45
+ if (armed && replace === 'arena-after-check' && file === S.arena && rest[0] && rest[0].bigint) { armed = false; swap(file); }
46
+ if (armed && replace === 'nested-during' && file === path.join(S.arena, 'nested')) { armed = false; swap(file); }
47
+ return st;
48
+ };
49
+ S.finish('arena cleanup child');
50
+ fs.lstatSync = lstat;
51
+ console.log('ARENA ' + S.arena + (armed ? ' (the replacement never happened)' : ''));
52
+ `], { encoding: 'utf8', windowsHide: true, timeout: 60000 });
53
+ const arenaOf = r => {
54
+ const line = r.stdout.split(/\r?\n/).find(l => l.startsWith('ARENA '));
55
+ assert(line && !line.includes('never happened'), r.stdout + r.stderr);
56
+ return line.slice(6);
57
+ };
58
+ const outsideIntact = () => {
59
+ assert.strictEqual(fs.lstatSync(path.join(elsewhere, 'keep-link')).isSymbolicLink(), true, 'the link behind the replacement must be kept');
60
+ assert.strictEqual(fs.readFileSync(path.join(elsewhere, 'keep.txt'), 'utf8'), 'kept');
61
+ assert.strictEqual(fs.readFileSync(sentinel, 'utf8'), 'kept');
62
+ };
63
+ try {
64
+ test('a passing run removes its arena, and a link to a folder outside it leaves that folder alone', () => {
65
+ const r = child(false);
66
+ assert.strictEqual(r.status, 0, r.stdout + r.stderr);
67
+ assert.strictEqual(fs.existsSync(arenaOf(r)), false, 'the arena must be gone');
68
+ assert.strictEqual(fs.readFileSync(sentinel, 'utf8'), 'kept', 'the link target must be untouched');
69
+ assert.match(r.stdout, /arena cleanup child: 1\/1 passed; fixtures: .*\(removed\)/);
70
+ });
71
+ test('a failing run keeps its arena for inspection', () => {
72
+ const r = child(true);
73
+ assert.strictEqual(r.status, 1, r.stdout + r.stderr);
74
+ const arena = arenaOf(r), link = path.join(arena, 'nested', 'link-out');
75
+ assert.strictEqual(fs.existsSync(arena), true, 'the arena must be kept');
76
+ assert.strictEqual(fs.lstatSync(link).isSymbolicLink(), true, 'its contents must be kept');
77
+ assert.match(r.stdout, /arena cleanup child: 0\/1 passed; fixtures: .*\(kept\)/);
78
+ unlinkLink(link);
79
+ fs.rmSync(arena, { recursive: true, force: true });
80
+ assert.strictEqual(fs.readFileSync(sentinel, 'utf8'), 'kept');
81
+ });
82
+ for (const [replace, what] of [['arena-before', 'an arena replaced by a link before finish()'],
83
+ ['arena-after-check', 'an arena replaced by a link right after its identity check'],
84
+ ['nested-during', 'a folder in the arena replaced by a link while finish() walks the arena']]) {
85
+ test(`${what} is not walked, and nothing behind the link is removed`, () => {
86
+ // Each case starts with `elsewhere` whole, so one failure does not decide the next case.
87
+ try { fs.lstatSync(path.join(elsewhere, 'keep-link')); } catch { fs.symlinkSync(outside, path.join(elsewhere, 'keep-link'), linkType); }
88
+ fs.writeFileSync(path.join(elsewhere, 'keep.txt'), 'kept');
89
+ const r = child(false, replace);
90
+ assert.strictEqual(r.status, 0, r.stdout + r.stderr);
91
+ const arena = arenaOf(r);
92
+ outsideIntact();
93
+ assert.match(r.stdout, /arena cleanup child: 1\/1 passed; fixtures: .*\(not removed: E_ARENA_REPLACED\)/);
94
+ const swapped = replace === 'nested-during' ? path.join(arena, 'nested') : arena;
95
+ unlinkLink(swapped);
96
+ unlinkLink(path.join(replace === 'nested-during' ? path.join(arena, 'nested-aside') : path.join(arena + '-aside', 'nested'), 'link-out'));
97
+ fs.rmSync(replace === 'nested-during' ? arena : arena + '-aside', { recursive: true, force: true });
98
+ });
99
+ }
100
+ } finally {
101
+ try { unlinkLink(path.join(elsewhere, 'keep-link')); } catch {}
102
+ fs.rmSync(elsewhere, { recursive: true, force: true });
103
+ fs.rmSync(outside, { recursive: true, force: true });
104
+ }
105
+ console.log(`memory arena cleanup probe: ${passed}/${passed + failed} passed (${process.platform})`);
106
+ process.exitCode = failed ? 1 : 0;
@@ -63,7 +63,7 @@ function run(project,args,config) {
63
63
  }
64
64
  let serial=0;
65
65
  function fixture(surface,legacy=false) {
66
- const project=path.join(temp,'case-'+(++serial)); fs.cpSync(seed,project,{recursive:true});
66
+ const project=path.join(temp,'case-'+(++serial)); require('../lib/copy-tree').copyTreeSync(seed,project);
67
67
  const p=ext=>path.join(project,'.leerness',surface+'.'+ext);
68
68
  const item=value=>surface==='decisions'?{date:'2026-01-01',title:value,decision:value,reason:null,alternatives:null,impact:null,extension:{keep:'line1\nline2'}}:{date:'2026-01-01',text:value,tag:null,extension:{keep:'line1\nline2'}};
69
69
  const body=value=>surface==='decisions'?`### 2026-01-01 — ${value}\n- Decision: ${value}\n`:`### 2026-01-01\n- Lesson: ${value}\n`;
@@ -36,7 +36,7 @@ const snapshot = root => Object.fromEntries([
36
36
  let sequence = 0;
37
37
  const fixture = () => {
38
38
  const root = path.join(temp, `case-${++sequence}`);
39
- fs.cpSync(seed, root, { recursive: true });
39
+ require('../lib/copy-tree').copyTreeSync(seed, root);
40
40
  return root;
41
41
  };
42
42
  const check = (name, fn) => {
@@ -52,7 +52,7 @@ const syncProjection = (root, surface, entries) => fs.writeFileSync(projection(r
52
52
  let sequence = 0;
53
53
  function fixture() {
54
54
  const root = path.join(temp, `case-${String(++sequence).padStart(3, '0')}`);
55
- fs.cpSync(seed, root, { recursive: true });
55
+ require('../lib/copy-tree').copyTreeSync(seed, root);
56
56
  return root;
57
57
  }
58
58
  function run(root, args) {
@@ -44,7 +44,7 @@ function fileState(file) {
44
44
  }
45
45
  function fixture(surface) {
46
46
  const project = path.join(temp, `case-${++sequence}`);
47
- fs.cpSync(seed, project, { recursive: true });
47
+ require('../lib/copy-tree').copyTreeSync(seed, project);
48
48
  const p = ext => path.join(project, '.leerness', `${surface}.${ext}`);
49
49
  const entry = value => surface === 'decisions'
50
50
  ? { date: '2026-09-12', title: value, decision: value, reason: 'reason', alternatives: null, impact: null }
@@ -131,7 +131,7 @@ try {
131
131
  });
132
132
  check(`${surface} fresh init remains usable and retains its template`, () => {
133
133
  const project = path.join(temp, `case-${++sequence}`);
134
- fs.cpSync(seed, project, { recursive: true });
134
+ require('../lib/copy-tree').copyTreeSync(seed, project);
135
135
  const md = path.join(project, '.leerness', `${surface}.md`);
136
136
  const original = fs.existsSync(md) ? fs.readFileSync(md, 'utf8') : '';
137
137
  success(run(project, commands(surface).add));
@@ -54,8 +54,46 @@ function privateMetadata(result) {
54
54
  const legacy = surface => surface === 'decisions'
55
55
  ? `# Personal notes\n### 2026-09-12 — Example\n- Decision: ${PRIVATE}\n- Reason: test\n`
56
56
  : `# Personal notes\n### 2026-09-12\n- Lesson: ${PRIVATE}\n- Tag: fixture\n`;
57
+ // T-0354: a run with no failures removes its arena; nothing did before, and every run of a memory probe left one in
58
+ // the temp directory (1,206 were found). A failed run keeps it for inspection. Links inside the arena are removed
59
+ // first, as links, so the removal never walks into a link's target, wherever that points. The arena itself must
60
+ // still be the folder made above (same file identity, not a link): a test that renamed it away and put a link in its
61
+ // place would otherwise have the walk run inside the link's target (review round 1).
62
+ const arenaIdentity = fs.lstatSync(arena, { bigint: true });
63
+ function sameArena() {
64
+ const st = fs.lstatSync(arena, { bigint: true });
65
+ return !st.isSymbolicLink() && st.isDirectory() && st.dev === arenaIdentity.dev && st.ino === arenaIdentity.ino;
66
+ }
67
+ // Review round 2: a folder can also be replaced by a link after its lstat, and the arena right after sameArena().
68
+ // So each folder must still resolve inside the arena just before it is listed and before a link in it is removed.
69
+ // finish() runs after the tests, so nothing else changes the arena then; a process that kept swapping folders
70
+ // during finish() could still race these checks.
71
+ const arenaReal = fs.realpathSync.native(arena);
72
+ const replaced = () => Object.assign(new Error('the arena was replaced'), { code: 'E_ARENA_REPLACED' });
73
+ function resolvesInArena(dir) {
74
+ const relative = path.relative(arenaReal, fs.realpathSync.native(dir));
75
+ return !(relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative));
76
+ }
77
+ function removeLinks(dir) {
78
+ if (!resolvesInArena(dir)) throw replaced();
79
+ for (const name of fs.readdirSync(dir)) {
80
+ const file = path.join(dir, name), st = fs.lstatSync(file);
81
+ if (st.isSymbolicLink()) {
82
+ if (!resolvesInArena(dir)) throw replaced();
83
+ try { fs.unlinkSync(file); } catch { fs.rmdirSync(file); }
84
+ } else if (st.isDirectory()) removeLinks(file);
85
+ }
86
+ }
57
87
  function finish(label) {
58
- console.log(`${label}: ${count - failed}/${count} passed; fixtures: ${arena}`);
59
- if (failed) process.exitCode = 1;
88
+ let kept = '';
89
+ if (failed) { process.exitCode = 1; kept = ' (kept)'; }
90
+ else {
91
+ try {
92
+ if (!sameArena()) throw replaced();
93
+ removeLinks(arena); require('./temp-cleanup').removeTree(arena, { recursive: true, force: true }); kept = ' (removed)';
94
+ }
95
+ catch (error) { kept = ` (not removed: ${error.code || error.message})`; }
96
+ }
97
+ console.log(`${label}: ${count - failed}/${count} passed; fixtures: ${arena}${kept}`);
60
98
  }
61
99
  module.exports = { assert, fs, path, cp, arena, entry, PRIVATE, test, project, snapshot, command, privateMetadata, legacy, finish };
@@ -107,7 +107,7 @@ function entry(surface, value) {
107
107
  function render(surface, entries) { return surface === 'decisions' ? _renderDecisionsMd(entries) : _renderLessonsMd(entries); }
108
108
  function fixture(surface, options = {}) {
109
109
  const project = path.join(temp, `case-${++sequence}`);
110
- fs.cpSync(seed, project, { recursive: true });
110
+ require('../lib/copy-tree').copyTreeSync(seed, project);
111
111
  const hd = path.join(project, '.leerness');
112
112
  const json = path.join(hd, `${surface}.json`);
113
113
  const md = path.join(hd, `${surface}.md`);
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+ // T-0350: a decisions or lessons restore with an explicit write policy must work when the project root is spelled
3
+ // differently from its real path. Until 1.36.297 the footprint capture named its snapshots under the realpath.native
4
+ // workspace while admission and preparation named the same files under the caller's spelling, so
5
+ // assertCheckedFileCurrent refused every such restore with "snapshot does not belong to this file".
6
+ // Spellings: as created, and on Windows a lowercase drive letter, a junction and the 8.3 short name (reported as
7
+ // skipped when the volume has no short names); elsewhere a symlink. Each restore must leave the same files as the
8
+ // restore through the real path. One MCP restore per surface uses a non-canonical spelling too.
9
+ const S = require('./memory-write-budget-support');
10
+ const { assert, fs, path, cp, arena, entry, test, finish, fixture, row, options } = S;
11
+ const { renderLosslessArchiveEntries } = require('../lib/memory-archive');
12
+ const policy = JSON.stringify({ limits: options(), mode: 'within-target',
13
+ targets: { items: 100, canonicalBytes: 65536, projectionBytes: 65536, archiveBytes: 65536, totalPathBytes: 200000 } });
14
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => /^(PATH|PATHEXT|SystemRoot|WINDIR|COMSPEC)$/i.test(k)));
15
+ for (const key of ['TEMP','TMP','USERPROFILE','APPDATA','LOCALAPPDATA']) { env[key] = path.join(arena,'env-'+key.toLowerCase()); fs.mkdirSync(env[key]); }
16
+ Object.assign(env,{ LEERNESS_INTERNAL: '1', LEERNESS_OFFLINE: '1', LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_AUTOCHCP: '1' });
17
+ const win = process.platform === 'win32';
18
+
19
+ const make = s => fixture(s,[row(s,'base')],'\nPRIVATE suffix\n',
20
+ { [s+'.archive.md']: '# Archive\n\n## 제거 2026-09-01 (target: "recover")\n'+renderLosslessArchiveEntries(s,[row(s,'revive')])+'\n' });
21
+ // What a restore leaves, read through the real path.
22
+ const state = (p, s) => ({ rows: JSON.parse(fs.readFileSync(path.join(p,'.leerness',s+'.json'),'utf8')),
23
+ projection: fs.readFileSync(path.join(p,'.leerness',s+'.md'),'utf8'), archive: fs.readFileSync(path.join(p,'.leerness',s+'.archive.md'),'utf8') });
24
+ // The 8.3 name of an existing folder, or null when the volume gives none.
25
+ function shortName(p) {
26
+ const r = cp.spawnSync(process.env.ComSpec || 'cmd.exe', ['/d','/s','/c',`"for %I in ("${p}") do @echo %~sI"`],
27
+ { encoding: 'utf8', windowsHide: true, windowsVerbatimArguments: true });
28
+ const out = String(r.stdout || '').trim();
29
+ return r.status === 0 && out && !out.includes('"') && out.toLowerCase() !== p.toLowerCase() ? out : null;
30
+ }
31
+ const spellings = [
32
+ ['as created', p => p],
33
+ ...(win ? [
34
+ ['a lowercase drive letter', p => /^[A-Z]:/.test(p) ? p[0].toLowerCase() + p.slice(1) : null],
35
+ ['a junction', p => { const link = p + '-junction'; fs.symlinkSync(p, link, 'junction'); return link; }],
36
+ ['the 8.3 short name', shortName],
37
+ ] : [
38
+ ['a symlink', p => { const link = p + '-symlink'; fs.symlinkSync(p, link, 'dir'); return link; }],
39
+ ]),
40
+ ];
41
+ function restoreCli(spelled, s) {
42
+ const r = cp.spawnSync(process.execPath,[entry,'memory','restore',s,'recover','--path',spelled,'--write-policy-json',policy,'--json'],
43
+ { cwd: spelled, env, encoding: 'utf8', timeout: 60000, maxBuffer: 1048576, windowsHide: true });
44
+ assert(!r.error && !r.signal, r.error && r.error.message);
45
+ return r;
46
+ }
47
+ function restoreMcp(spelled, s) {
48
+ const request = { jsonrpc: '2.0', id: 1, method: 'tools/call',
49
+ params: { name: 'leerness_memory_restore', arguments: { surface: s, target: 'recover', path: spelled, writePolicyJson: policy } } };
50
+ const r = cp.spawnSync(process.execPath,[entry,'mcp','serve','--profile','full'],
51
+ { cwd: spelled, env, input: JSON.stringify(request)+'\n', encoding: 'utf8', timeout: 60000, maxBuffer: 1048576, windowsHide: true });
52
+ assert(!r.error && !r.signal, r.error && r.error.message); assert.equal(r.status,0,r.stderr);
53
+ return JSON.parse(r.stdout.trim().split(/\r?\n/)[0]);
54
+ }
55
+ // T-0355: a spelling this machine cannot give counts as absent: a test arena on a UNC path has no drive letter, and
56
+ // a link may be refused there. The MCP and resolve-check tests take the first non-canonical spelling that exists.
57
+ const trySpell = (spell, p) => { try { return spell(p); } catch { return null; } };
58
+ function alternate(p) {
59
+ for (const [name, spell] of spellings.slice(1)) { const spelled = trySpell(spell, p); if (spelled && spelled !== p) return [name, spelled]; }
60
+ return null;
61
+ }
62
+
63
+ for (const s of ['decisions','lessons']) {
64
+ let expected = null;
65
+ for (const [name, spell] of spellings) {
66
+ const p = make(s), spelled = trySpell(spell, p);
67
+ if (spelled === null) { console.log(`SKIP ${s}: policy restore through ${name} (this volume or path gives no such spelling)`); continue; }
68
+ test(`${s}: policy restore through ${name} matches the restore through the real path`, () => {
69
+ if (name !== 'as created') assert.notStrictEqual(spelled, p, 'the spelling must differ from the real path');
70
+ const r = restoreCli(spelled, s);
71
+ assert.equal(r.status, 0, r.stdout + r.stderr);
72
+ const after = state(p, s);
73
+ assert.deepEqual(after.rows.map(entry => entry.title || entry.text), ['base','revive']);
74
+ assert(!after.archive.includes('target: "recover"'), 'the restored block must leave the archive');
75
+ if (expected === null) expected = after; else assert.deepEqual(after, expected);
76
+ });
77
+ }
78
+ const pm = make(s), alt = alternate(pm);
79
+ if (!alt) { console.log(`SKIP ${s}: MCP policy restore (this volume or path gives no non-canonical spelling)`); continue; }
80
+ const [mcpName, spelled] = alt; // a lowercase drive letter on Windows when there is one, a symlink elsewhere
81
+ test(`${s}: MCP policy restore through ${mcpName}`, () => {
82
+ const p = pm;
83
+ const reply = restoreMcp(spelled, s);
84
+ assert(reply.result, JSON.stringify(reply)); assert.equal(reply.result.isError, false, JSON.stringify(reply));
85
+ assert.deepEqual(state(p, s).rows.map(entry => entry.title || entry.text), ['base','revive']);
86
+ });
87
+ }
88
+ // The capture names files under the given spelling only after that spelling's workspace resolves to the resolved
89
+ // one. Make it resolve elsewhere (as a swapped link would between the two resolutions) and the capture must refuse
90
+ // before reading a file; without the check the files of the other folder would be measured as this project's.
91
+ const resolveCheckProject = make('decisions'), resolveCheckAlternate = alternate(resolveCheckProject);
92
+ if (!resolveCheckAlternate) console.log('SKIP a spelled workspace that resolves to another folder (no non-canonical spelling here)');
93
+ else test('a spelled workspace that resolves to another folder is refused before any file is read', () => {
94
+ const { inspectMemoryFootprint } = require('../lib/memory-footprint');
95
+ const p = resolveCheckProject, other = make('decisions'), spelled = resolveCheckAlternate[1];
96
+ const limits = JSON.stringify(options().observation);
97
+ assert.equal(inspectMemoryFootprint(spelled,'decisions',limits).ok, true, 'control: the spelling itself is accepted');
98
+ const workspace = path.join(spelled,'.leerness'), same = (a, b) => win ? a.toLowerCase() === b.toLowerCase() : a === b;
99
+ const real = fs.realpathSync.native, opened = [], open = fs.openSync;
100
+ fs.realpathSync.native = function (file, ...rest) {
101
+ return real.call(fs, same(path.resolve(String(file)), path.resolve(workspace)) ? path.join(other,'.leerness') : file, ...rest);
102
+ };
103
+ fs.openSync = function (file, ...rest) { opened.push(String(file)); return open.call(fs, file, ...rest); };
104
+ try {
105
+ const result = inspectMemoryFootprint(spelled,'decisions',limits);
106
+ assert.equal(result.ok, false); assert.equal(result.error.code, 'workspace_unavailable');
107
+ assert.deepEqual(opened.filter(file => /\.leerness[\\/]decisions\./.test(file)), [], 'no store file may be opened');
108
+ } finally { fs.realpathSync.native = real; fs.openSync = open; }
109
+ });
110
+ finish('memory policy root spelling');
@@ -39,7 +39,7 @@ function run(project, args) {
39
39
  function success(r) { assert.strictEqual(r.status, 0, String(r.stdout || r.stderr).slice(-1000)); }
40
40
  function fixture(surface) {
41
41
  const project = path.join(temp, `case-${++sequence}`);
42
- fs.cpSync(seed, project, { recursive: true });
42
+ require('../lib/copy-tree').copyTreeSync(seed, project);
43
43
  const hd = path.join(project, '.leerness');
44
44
  const p = ext => path.join(hd, `${surface}.${ext}`);
45
45
  const entry = surface === 'decisions'
@@ -5,7 +5,9 @@ const os=require('os'),runtime=require('../lib/runtime-writes'),{attachMemoryRec
5
5
  // Node 18 writeFileSync dispatches through the later intercepted writeSync.
6
6
  // Capture primitive fixture writers before installing interception, so descriptor
7
7
  // changes represent an external writer rather than the guarded operation itself.
8
- const rawOpen=fs.openSync.bind(fs),rawWriteSync=fs.writeSync.bind(fs),rawClose=fs.closeSync.bind(fs);
8
+ const rawOpen=fs.openSync.bind(fs),rawWriteSync=fs.writeSync.bind(fs),rawClose=fs.closeSync.bind(fs),rawRename=fs.renameSync.bind(fs);
9
+ const rawSymlink=fs.symlinkSync.bind(fs),rawUnlink=fs.unlinkSync.bind(fs),rawRmdir=fs.rmdirSync.bind(fs);
10
+ const rawUnlinkLink=link=>{try{rawUnlink(link);}catch{rawRmdir(link);}};
9
11
  function rawWrite(file,text){const fd=rawOpen(file,'w');try{rawWriteSync(fd,Buffer.from(text));}finally{rawClose(fd);}}
10
12
  const descriptor={schema:'leerness.runtime-layout/v1',schemaVersion:1,scope:'project-local',generation:1,layout:'legacy',requiredWriterProtocol:1};
11
13
  // Begin in the supported implicit legacy layout. Creating an incompatible
@@ -36,6 +38,71 @@ test('a temp directory inside the project is rejected before producer work',()=>
36
38
  os.tmpdir=()=>p;try{assert.throws(()=>runtime.writeExternalRecoveryStream(p,()=>{called=true;},8),e=>e.code==='E_RECOVERY_TEMP_UNSAFE');}finally{os.tmpdir=original;}
37
39
  });assert.equal(called,false);
38
40
  });
41
+ // T-0349: the same folder spelled so that its text is outside the project: through a link placed outside it, and
42
+ // on Windows by its 8.3 short name. The guard compared spellings only and let both through.
43
+ function shortName(p){const r=cp.spawnSync(process.env.ComSpec||'cmd.exe',['/d','/s','/c',`"for %I in ("${p}") do @echo %~sI"`],{encoding:'utf8',windowsHide:true,windowsVerbatimArguments:true});const out=String(r.stdout||'').trim();return r.status===0&&out&&!out.includes('"')&&out.toLowerCase()!==p.toLowerCase()?out:null;}
44
+ const insideSpellings=[['a link outside the project',(p,inside)=>{const link=p+'-temp-link';fs.symlinkSync(inside,link,process.platform==='win32'?'junction':'dir');return link;}],
45
+ ...(process.platform==='win32'?[['its 8.3 short name',(p,inside)=>shortName(inside)]]:[])];
46
+ for(const [name,spell] of insideSpellings){
47
+ const p=project(),inside=path.join(p,'temp-folder-with-a-long-name');fs.mkdirSync(inside);const spelled=spell(p,inside);
48
+ if(!spelled){console.log('SKIP a temp folder inside the project named by '+name+' (no such spelling here)');continue;}
49
+ test('a temp folder inside the project named by '+name+' is rejected before producer work',()=>{
50
+ assert(path.relative(p,spelled).startsWith('..'),'the spelling must look outside the project: '+spelled);
51
+ const original=os.tmpdir;let called=false;latch(p,()=>{
52
+ os.tmpdir=()=>spelled;try{assert.throws(()=>runtime.writeExternalRecoveryStream(p,()=>{called=true;},8),e=>e.code==='E_RECOVERY_TEMP_UNSAFE');}finally{os.tmpdir=original;}
53
+ });assert.equal(called,false);assert.deepEqual(fs.readdirSync(inside),[]);
54
+ });
55
+ }
56
+ // T-0349 review: a project folder renamed away while the operation runs has no real path. Its recovery must still be
57
+ // written outside it, as before; only a temp folder that does not resolve is refused.
58
+ test('a project renamed away during the operation still gets its recovery outside it',()=>{
59
+ const p=project(),moved=p+'-moved';let dir;latch(p,()=>{
60
+ rawRename(p,moved);try{dir=runtime.writeExternalRecoveryStream(p,sink=>sink.write('before.bin',put=>put(Buffer.from('before'))),8);}finally{rawRename(moved,p);}
61
+ });
62
+ external.push(dir);assert(path.relative(p,dir).startsWith('..'));assert.equal(fs.readFileSync(path.join(dir,'before.bin'),'utf8'),'before');
63
+ });
64
+ // Review round 1: a child folder whose name starts with two dots is inside the project, named directly and through a
65
+ // link outside the project (only the real paths then show it).
66
+ for(const [name,spell] of [['directly',(p,inside)=>inside],['through a link outside the project',(p,inside)=>{const link=p+'-dots-link';fs.symlinkSync(inside,link,process.platform==='win32'?'junction':'dir');return link;}]]){
67
+ test('a temp folder inside the project whose name starts with two dots, named '+name+', is rejected before producer work',()=>{
68
+ const p=project(),inside=path.join(p,'..temp');fs.mkdirSync(inside);const spelled=spell(p,inside),original=os.tmpdir;let called=false;latch(p,()=>{
69
+ os.tmpdir=()=>spelled;try{assert.throws(()=>runtime.writeExternalRecoveryStream(p,()=>{called=true;},8),e=>e.code==='E_RECOVERY_TEMP_UNSAFE');}finally{os.tmpdir=original;}
70
+ });assert.equal(called,false);assert.deepEqual(fs.readdirSync(inside),[]);
71
+ });
72
+ }
73
+ test('a project path that fails to resolve for a reason other than absence is refused',()=>{
74
+ const p=project(),original=fs.realpathSync.native;let called=false;latch(p,()=>{
75
+ fs.realpathSync.native=function(q,...rest){if(path.resolve(String(q))===path.resolve(p))throw Object.assign(new Error('EIO: simulated'),{code:'EIO'});return original.call(this,q,...rest);};
76
+ try{assert.throws(()=>runtime.writeExternalRecoveryStream(p,()=>{called=true;},8),e=>e.code==='E_RECOVERY_TEMP_UNSAFE');}finally{fs.realpathSync.native=original;}
77
+ });assert.equal(called,false);
78
+ });
79
+ // Review round 2: the temp link is retargeted into the project after it was checked (here, while the guard resolves
80
+ // the project path). The bundle must still go below the folder that was checked.
81
+ test('a temp link retargeted into the project after the check still gets the recovery outside the project',()=>{
82
+ const p=project(),inside=path.join(p,'temp-after-retarget');fs.mkdirSync(inside);
83
+ const safe=fs.mkdtempSync(path.join(path.dirname(p),'safe-temp-')),link=p+'-temp-retarget-link',type=process.platform==='win32'?'junction':'dir';
84
+ rawSymlink(safe,link,type);
85
+ const originalTmp=os.tmpdir,originalReal=fs.realpathSync.native;let dir,retargeted=false;
86
+ latch(p,()=>{
87
+ os.tmpdir=()=>link;
88
+ fs.realpathSync.native=function(q,...rest){if(!retargeted&&path.resolve(String(q))===path.resolve(p)){retargeted=true;rawUnlinkLink(link);rawSymlink(inside,link,type);}return originalReal.call(this,q,...rest);};
89
+ try{dir=runtime.writeExternalRecoveryStream(p,sink=>sink.write('before.bin',put=>put(Buffer.from('before'))),8);}finally{os.tmpdir=originalTmp;fs.realpathSync.native=originalReal;}
90
+ });
91
+ external.push(dir);assert(retargeted,'the link must have been retargeted during the check');
92
+ assert.deepEqual(fs.readdirSync(inside),[]);assert.equal(fs.readFileSync(path.join(dir,'before.bin'),'utf8'),'before');
93
+ assert(path.relative(fs.realpathSync.native(p),fs.realpathSync.native(dir)).split(path.sep)[0]==='..','the recovery must be outside the project: '+dir);
94
+ });
95
+ test('a missing temp folder is refused when the project path is gone too',()=>{
96
+ const p=project(),moved=p+'-moved-too',original=os.tmpdir;let called=false;latch(p,()=>{
97
+ rawRename(p,moved);os.tmpdir=()=>path.join(path.dirname(p),'missing-temp-folder');
98
+ try{assert.throws(()=>runtime.writeExternalRecoveryStream(p,()=>{called=true;},8),e=>e.code==='E_RECOVERY_TEMP_UNSAFE');}finally{os.tmpdir=original;rawRename(moved,p);}
99
+ });assert.equal(called,false);
100
+ });
101
+ test('a temp folder that does not resolve is refused before producer work',()=>{
102
+ const p=project(),original=os.tmpdir;let called=false;latch(p,()=>{
103
+ os.tmpdir=()=>path.join(path.dirname(p),'missing-temp-folder');try{assert.throws(()=>runtime.writeExternalRecoveryStream(p,()=>{called=true;},8),e=>e.code==='E_RECOVERY_TEMP_UNSAFE');}finally{os.tmpdir=original;}
104
+ });assert.equal(called,false);
105
+ });
39
106
  test('invalid chunk admission occurs before creating an external namespace',()=>{
40
107
  const p=project();latch(p,()=>{for(const value of [0,-1,65537,1.5,'8'])assert.throws(()=>runtime.writeExternalRecoveryStream(p,()=>{},value),e=>e.code==='E_RECOVERY_BUNDLE_INVALID');});
41
108
  });
@@ -97,7 +97,7 @@ function entry(surface, value) {
97
97
  function render(surface, entries) { return surface === 'decisions' ? _renderDecisionsMd(entries) : _renderLessonsMd(entries); }
98
98
  function fixture(surface) {
99
99
  const project = path.join(temp, `case-${++sequence}`);
100
- fs.cpSync(seed, project, { recursive: true });
100
+ require('../lib/copy-tree').copyTreeSync(seed, project);
101
101
  const hd = path.join(project, '.leerness');
102
102
  const json = path.join(hd, `${surface}.json`);
103
103
  const md = path.join(hd, `${surface}.md`);
@@ -54,7 +54,7 @@ function check(name, fn) {
54
54
  }
55
55
  function fixture(surface) {
56
56
  const project = path.join(temp, `case-${++sequence}`);
57
- fs.cpSync(seed, project, { recursive: true });
57
+ require('../lib/copy-tree').copyTreeSync(seed, project);
58
58
  const file = ext => path.join(project, '.leerness', `${surface}.${ext}`);
59
59
  const identity = surface === 'decisions' ? 'title' : 'text';
60
60
  const item = (value, extra = {}) => surface === 'decisions'
@@ -54,7 +54,7 @@ function success(result) {
54
54
  }
55
55
  function fixture(surface, payload, bom = false) {
56
56
  const project = path.join(temp, `case-${++sequence}`);
57
- fs.cpSync(seed, project, { recursive: true });
57
+ require('../lib/copy-tree').copyTreeSync(seed, project);
58
58
  const dir = path.join(project, '.leerness');
59
59
  const file = ext => path.join(dir, `${surface}.${ext}`);
60
60
  const item = identity => surface === 'decisions'