leerness 1.36.185 → 1.36.187

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 (51) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.ko.md +5 -0
  3. package/README.md +14 -4
  4. package/bin/leerness.js +291 -57
  5. package/docs/e2e-observation-contract.md +51 -0
  6. package/docs/runtime-compatibility-api.md +183 -0
  7. package/docs/state-paths-api.md +92 -0
  8. package/docs/state-scopes.md +266 -0
  9. package/docs/worktree-runtime-migration.md +278 -0
  10. package/lib/claims-baseline.js +1 -1
  11. package/lib/file-leases.js +2 -2
  12. package/lib/git.js +64 -15
  13. package/lib/io.js +9 -3
  14. package/lib/migrate.js +1 -1
  15. package/lib/preview-serve.js +5 -1
  16. package/lib/pure-utils.js +20 -0
  17. package/lib/role-fallback.js +3 -3
  18. package/lib/role-store.js +2 -2
  19. package/lib/runtime-layout.js +395 -0
  20. package/lib/runtime-writes.js +166 -0
  21. package/lib/session-close.js +1 -1
  22. package/lib/state-git.js +138 -0
  23. package/lib/state-inspect.js +39 -0
  24. package/lib/state-inventory.js +69 -0
  25. package/lib/state-paths.js +59 -0
  26. package/lib/workspace-dir.js +34 -10
  27. package/package.json +10 -6
  28. package/scripts/command-flags-probe.js +1 -1
  29. package/scripts/dead-flags-probe.js +1 -1
  30. package/scripts/e2e-child-diagnostics-probe.js +143 -0
  31. package/scripts/e2e-child-diagnostics.js +33 -0
  32. package/scripts/e2e-temp-scope-probe.js +127 -0
  33. package/scripts/e2e.js +108 -27
  34. package/scripts/encoding-selftest-probe.js +222 -0
  35. package/scripts/false-claim-probe.js +34 -3
  36. package/scripts/file-lease-probe.js +5 -2
  37. package/scripts/legacy-runtime-negative-probe.js +72 -0
  38. package/scripts/mutation-integrity-probe.js +3 -0
  39. package/scripts/role-fallback-probe.js +67 -6
  40. package/scripts/runtime-admission-probe.js +282 -0
  41. package/scripts/runtime-git-write-probe.js +116 -0
  42. package/scripts/runtime-layout-probe.js +1198 -0
  43. package/scripts/runtime-repl-probe.js +271 -0
  44. package/scripts/runtime-replacement-probe.js +190 -0
  45. package/scripts/runtime-write-probe.js +458 -0
  46. package/scripts/selftest-cleanup-probe.js +168 -0
  47. package/scripts/state-inspect-cli-probe.js +209 -0
  48. package/scripts/state-scopes-probe.js +590 -0
  49. package/scripts/workspace-dir-lock-order-probe.js +310 -35
  50. package/scripts/workspace-dir-migration-probe.js +17 -2
  51. package/scripts/workspace-selection-probe.js +230 -0
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // T-0181: test the confirmed UR-0113 diagnostic/exit-code defect separately
5
+ // from the unconfirmed Windows CI failure. The default violates the platform's
6
+ // BOM contract: Windows misses its required replacement, whereas POSIX receives
7
+ // a forbidden BOM in the disposable fixture. Both must retain failure details
8
+ // and restore the caller's exit code. --real-controls uses the actual writer.
9
+ const assert = require('assert');
10
+ const cp = require('child_process');
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+
15
+ const PREFIX = 'ENCODING_SELFTEST_RESULT:';
16
+ const MISSING = 'ENCODING_DIAGNOSTICS_MISSING';
17
+ const MAX_DIAGNOSTIC = 4096;
18
+ const FIXTURE_PREFIX = 'leerness-encoding-selftest-';
19
+
20
+ function samePath(left, right) {
21
+ const normalize = value => process.platform === 'win32'
22
+ ? path.resolve(value).toLowerCase() : path.resolve(value);
23
+ return normalize(left) === normalize(right);
24
+ }
25
+
26
+ function child(kind, arena, priorExit) {
27
+ assert(['fault', 'canonical', 'junction'].includes(kind), 'unknown child scenario');
28
+ assert(path.basename(arena).startsWith(FIXTURE_PREFIX), 'unexpected fixture name');
29
+ assert(samePath(fs.realpathSync.native(arena), arena), 'fixture must be canonical');
30
+ const canonical = path.join(arena, 'canonical-temp');
31
+ const selected = kind === 'junction' ? path.join(arena, 'junction-temp') : canonical;
32
+ assert(samePath(fs.realpathSync.native(selected), canonical), 'unexpected TEMP target');
33
+ assert(fs.statSync(path.join(arena, '.leerness')).isDirectory(), 'missing recovery boundary');
34
+ const previousTmpdir = os.tmpdir;
35
+ os.tmpdir = () => selected;
36
+ let injectedCalls = 0;
37
+ let injectedByteChanges = 0;
38
+ let unexpectedReplacementPlans = 0;
39
+ try {
40
+ const encoding = require('../lib/shell-encoding');
41
+ if (kind === 'fault') {
42
+ // Install before bin imports the export by value. Other fixture files
43
+ // retain the real planner's no-op action, without calling a writer.
44
+ encoding.applyShellScriptUtf8Bom = (file, displayFile = file) => {
45
+ if (path.basename(file) === 's.ps1') {
46
+ injectedCalls++;
47
+ if (process.platform !== 'win32') {
48
+ // A pre-replacement timeout alone leaves valid POSIX bytes and
49
+ // therefore does not violate UR-0113's no-change contract. Model
50
+ // an erroneous POSIX repair instead, confined to this fixture.
51
+ const original = fs.readFileSync(file);
52
+ const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
53
+ assert(!original.subarray(0, bom.length).equals(bom), 'fault fixture already has a BOM');
54
+ fs.writeFileSync(file, Buffer.concat([bom, original]));
55
+ injectedByteChanges++;
56
+ }
57
+ const error = new Error('controlled encoding probe timeout: ' + 'x'.repeat(8192));
58
+ error.code = 'ETIMEDOUT';
59
+ throw error;
60
+ }
61
+ const plan = encoding.planShellScriptUtf8Bom(file, fs.readFileSync(file));
62
+ if (plan.bytes) unexpectedReplacementPlans++;
63
+ return { file: displayFile, action: plan.action };
64
+ };
65
+ }
66
+ const cases = require('../bin/leerness')._selfTestCases()
67
+ .filter(test => test.name.includes('UR-0113'));
68
+ assert.strictEqual(cases.length, 1, 'expected exactly one UR-0113 case');
69
+ process.exitCode = priorExit === 'unset' ? undefined : Number(priorExit);
70
+ const beforeExit = process.exitCode;
71
+ let returned = null;
72
+ let thrown = null;
73
+ try {
74
+ const result = cases[0].run();
75
+ returned = typeof result === 'boolean' ? result : null;
76
+ } catch (error) {
77
+ const message = String(error && error.message || error);
78
+ thrown = {
79
+ message: message.slice(0, MAX_DIAGNOSTIC + 1),
80
+ length: message.length,
81
+ hasTimeoutCode: message.includes('ETIMEDOUT'),
82
+ hasFileName: message.includes('s.ps1'),
83
+ platformContractFailed: message.includes('"platformOk":false'),
84
+ };
85
+ }
86
+ const afterExit = process.exitCode;
87
+ // Child status is transport status; report the case's actual exit state.
88
+ process.exitCode = 0;
89
+ process.stdout.write(PREFIX + JSON.stringify({
90
+ kind, node: process.version, platform: process.platform,
91
+ beforeExit: beforeExit === undefined ? null : beforeExit,
92
+ afterExit: afterExit === undefined ? null : afterExit,
93
+ returned, thrown, injectedCalls, injectedByteChanges, unexpectedReplacementPlans,
94
+ }) + '\n');
95
+ } finally {
96
+ os.tmpdir = previousTmpdir;
97
+ }
98
+ }
99
+
100
+ function childEnvironment(arena) {
101
+ const env = { ...process.env };
102
+ // The probe must not inherit ambient runtime descriptors, provider choices,
103
+ // or Node preload hooks. TEMP is overridden inside the isolated child.
104
+ for (const name of Object.keys(env)) {
105
+ if (/^LEERNESS_/i.test(name) || /^NODE_OPTIONS$/i.test(name)) delete env[name];
106
+ }
107
+ return { ...env, LEERNESS_OFFLINE: '1', LEERNESS_NO_AUTOCHCP: '1',
108
+ GIT_CEILING_DIRECTORIES: arena };
109
+ }
110
+
111
+ function runChild(kind, arena, priorExit = 'unset') {
112
+ const result = cp.spawnSync(process.execPath, [__filename, '--child', kind, arena, priorExit], {
113
+ cwd: arena, env: childEnvironment(arena), encoding: 'utf8',
114
+ shell: false, windowsHide: true,
115
+ timeout: kind === 'fault' ? 15000 : 45000, maxBuffer: 64 * 1024,
116
+ });
117
+ assert(!result.error, `child failed: ${result.error && result.error.code}`);
118
+ assert.strictEqual(result.status, 0,
119
+ `child exit ${result.status}: ${String(result.stderr || '').slice(0, 1000)}`);
120
+ const reports = String(result.stdout || '').split(/\r?\n/)
121
+ .filter(line => line.startsWith(PREFIX));
122
+ assert.strictEqual(reports.length, 1, 'child must emit exactly one observation');
123
+ return JSON.parse(reports[0].slice(PREFIX.length));
124
+ }
125
+
126
+ function summary(result) {
127
+ return JSON.stringify({
128
+ kind: result.kind, platform: result.platform, beforeExit: result.beforeExit, afterExit: result.afterExit,
129
+ returned: result.returned, injectedCalls: result.injectedCalls, injectedByteChanges: result.injectedByteChanges,
130
+ thrown: result.thrown && {
131
+ length: result.thrown.length,
132
+ hasTimeoutCode: result.thrown.hasTimeoutCode,
133
+ hasFileName: result.thrown.hasFileName,
134
+ platformContractFailed: result.thrown.platformContractFailed,
135
+ },
136
+ });
137
+ }
138
+
139
+ function main() {
140
+ const args = process.argv.slice(2);
141
+ if (args[0] === '--child') return child(args[1], args[2], args[3]);
142
+ if (args.length > 1 || (args.length && !['--expect-legacy', '--repro', '--real-controls', '--help'].includes(args[0]))) {
143
+ throw new Error('usage: node scripts/encoding-selftest-probe.js [--expect-legacy|--repro|--real-controls]');
144
+ }
145
+ if (args[0] === '--help') {
146
+ process.stdout.write('Default / --repro: violate the platform BOM contract; require bounded ETIMEDOUT diagnostics and restored exit code.\n'
147
+ + '--expect-legacy: succeed only when silent failure and exit-code leakage are reproduced.\n'
148
+ + '--real-controls: run the real canonical/junction TEMP happy cases separately.\n');
149
+ return;
150
+ }
151
+ const mode = args[0] || '--repro';
152
+ const tempParent = fs.realpathSync.native(os.tmpdir());
153
+ const arena = fs.realpathSync.native(fs.mkdtempSync(path.join(tempParent, FIXTURE_PREFIX)));
154
+ const canonical = path.join(arena, 'canonical-temp');
155
+ const junction = path.join(arena, 'junction-temp');
156
+ const failures = [];
157
+ let total = 0;
158
+ try {
159
+ // Confine the preservation helper's ancestor search and recovery artifacts
160
+ // to this exclusive fixture, even if the user's TEMP has another harness.
161
+ fs.mkdirSync(path.join(arena, '.leerness'));
162
+ fs.mkdirSync(canonical);
163
+ const scenarios = mode === '--real-controls'
164
+ ? [['canonical', 'unset'], ['junction', 'unset']]
165
+ : [['fault', 'unset'], ['fault', '23']];
166
+ if (mode === '--real-controls') {
167
+ fs.symlinkSync(canonical, junction, process.platform === 'win32' ? 'junction' : 'dir');
168
+ }
169
+ for (const [kind, priorExit] of scenarios) {
170
+ total++;
171
+ const label = `${kind}, prior exit ${priorExit}`;
172
+ try {
173
+ const result = runChild(kind, arena, priorExit);
174
+ if (mode === '--real-controls') {
175
+ assert.strictEqual(result.returned, true, summary(result));
176
+ assert.strictEqual(result.thrown, null, summary(result));
177
+ assert.strictEqual(result.afterExit, result.beforeExit, summary(result));
178
+ } else {
179
+ assert.strictEqual(result.injectedCalls, 1, 'fault must reach s.ps1 exactly once');
180
+ assert.strictEqual(result.injectedByteChanges, result.platform === 'win32' ? 0 : 1,
181
+ 'fault must violate the platform BOM contract');
182
+ assert.strictEqual(result.unexpectedReplacementPlans, 0, 'fault probe must not need another replacement');
183
+ const legacy = result.returned === false && !result.thrown
184
+ && result.afterExit === 1 && result.afterExit !== result.beforeExit;
185
+ if (mode === '--expect-legacy') {
186
+ assert(legacy, `legacy defect was not reproduced: ${summary(result)}`);
187
+ process.stdout.write(`${MISSING}: reproduced ${label}\n`);
188
+ } else {
189
+ assert(result.thrown && result.thrown.hasTimeoutCode && result.thrown.hasFileName
190
+ && result.thrown.platformContractFailed
191
+ && result.thrown.length <= MAX_DIAGNOSTIC,
192
+ `${MISSING}: ${summary(result)}`);
193
+ assert.strictEqual(result.afterExit, result.beforeExit,
194
+ `ENCODING_EXIT_CODE_NOT_RESTORED: ${summary(result)}`);
195
+ }
196
+ }
197
+ process.stdout.write(`ok - ${label}\n`);
198
+ } catch (error) {
199
+ failures.push(label);
200
+ process.stderr.write(`not ok - ${label}: ${String(error.message).slice(0, MAX_DIAGNOSTIC)}\n`);
201
+ }
202
+ }
203
+ } finally {
204
+ if (fs.existsSync(junction)) {
205
+ assert(fs.lstatSync(junction).isSymbolicLink(), 'refusing unexpected cleanup target');
206
+ assert(samePath(fs.realpathSync.native(junction), canonical), 'refusing changed junction target');
207
+ fs.unlinkSync(junction);
208
+ }
209
+ assert(path.basename(arena).startsWith(FIXTURE_PREFIX), 'refusing unexpected fixture cleanup');
210
+ assert(samePath(path.dirname(arena), tempParent), 'fixture cleanup escaped TEMP');
211
+ assert(samePath(fs.realpathSync.native(arena), arena), 'fixture cleanup target changed');
212
+ fs.rmSync(arena, { recursive: true, force: true, maxRetries: 5, retryDelay: 40 });
213
+ }
214
+ process.stdout.write(`encoding selftest probe: ${total - failures.length}/${total} PASS (${mode})\n`);
215
+ if (failures.length) process.exitCode = 1;
216
+ }
217
+
218
+ try { main(); }
219
+ catch (error) {
220
+ process.stderr.write(`encoding selftest probe error: ${String(error.message).slice(0, MAX_DIAGNOSTIC)}\n`);
221
+ process.exitCode = 1;
222
+ }
@@ -296,10 +296,16 @@ try {
296
296
  { label: 'handoff', args: ['handoff', '--json', '--no-record', '--no-drift-check', '--no-headline'], timeout: 180000 },
297
297
  { label: 'session close', args: ['session', 'close', '--json'], timeout: 300000 },
298
298
  ]) {
299
+ const before = treeDigest(root);
299
300
  result = run(root, aggregate.args, aggregate.timeout, true, { PATH: noGitPath });
300
301
  parsed = json(result);
301
- check(`${aggregate.label} preserves null/error provenance when Git is unavailable`,
302
- result.status === 0 && aggregateHistoryUnavailable(parsed), result);
302
+ // These aggregate commands have bookkeeping/writers. Without Git in a
303
+ // repository they cannot establish layout authority, unlike the pure
304
+ // history readers above. History-only faults below still exercise all
305
+ // original null/error aggregation assertions with working topology.
306
+ check(`${aggregate.label} rejects unresolvable repository authority before writing`,
307
+ result.status === 1 && parsed?.code === 'runtime_layout_incompatible'
308
+ && parsed.error.includes('(git_missing)') && treeDigest(root) === before, result);
303
309
  }
304
310
  } finally {
305
311
  fs.rmSync(noGitPath, { recursive: true, force: true });
@@ -403,6 +409,7 @@ try {
403
409
  ['preview', ['preview']],
404
410
  ['preview', ['preview', 'list']],
405
411
  ['preview', ['preview', 'show', 'P-0001']],
412
+ ['state', ['state', 'inspect']],
406
413
  ].every(([cmd, argv]) => _cliMutationClass(argv, cmd) === 'observation-only'));
407
414
  let readOnlyStatusesOk = true;
408
415
  for (const args of [
@@ -421,6 +428,7 @@ try {
421
428
  ['security-surface', '--json'],
422
429
  ['preview', '--json'],
423
430
  ['preview', 'list', '--json'],
431
+ ['state', 'inspect', '--json'],
424
432
  ]) {
425
433
  const readOnlyResult = run(root, args);
426
434
  if (readOnlyResult.status !== 0) readOnlyStatusesOk = false;
@@ -441,6 +449,7 @@ try {
441
449
  { args: ['identity', '--json'], status: 0 },
442
450
  { args: ['status', '--json'], status: 0 },
443
451
  { args: ['auto-update', 'status', '--json'], status: 0 },
452
+ { args: ['state', 'inspect', '--json'], status: 0 },
444
453
  // These surfaces currently require the canonical workspace to render;
445
454
  // on legacy input they may fail read-only, but must never migrate it.
446
455
  { args: ['dashboard', '--json'], status: 1 },
@@ -1433,6 +1442,29 @@ try {
1433
1442
  fs.writeFileSync(linkedStore, JSON.stringify([{ id: 'P-0001', title: 'linked output', status: 'proposed', features: [], history: [] }]), 'utf8');
1434
1443
  const linkedStoreBefore = digest(linkedStore);
1435
1444
 
1445
+ // A partial standalone store is rejected at admission. An initialized
1446
+ // workspace must still reach the original domain-specific link guards.
1447
+ result = run(linkedOutputRoot, ['preview', 'mockup', 'P-0001', '--json']);
1448
+ parsed = json(result);
1449
+ check('standalone linked preview output is rejected before CLI bookkeeping',
1450
+ result.status === 1 && parsed?.code === 'runtime_layout_incompatible'
1451
+ && parsed.error.includes('(workspace_ambiguous)')
1452
+ && digest(linkedStore) === linkedStoreBefore
1453
+ && !fs.existsSync(path.join(linkedOutputRoot, '.leerness/cache'))
1454
+ && fs.readdirSync(linkedOutputTarget).length === 0, result);
1455
+ const previewServe = require('../lib/preview-serve');
1456
+ for (const operation of [
1457
+ () => previewServe.buildServeWorkspace(linkedOutputRoot, { id: 'P-0001', mockupPath: 'mock.html' }),
1458
+ () => previewServe.cleanupServeDir(linkedOutputRoot, 'P-0001'),
1459
+ ]) {
1460
+ let caught;
1461
+ try { operation(); } catch (error) { caught = error; }
1462
+ check('standalone direct preview writer rejects linked output without writes',
1463
+ caught?.code === 'E_RUNTIME_LAYOUT_INCOMPATIBLE' && caught.reasonCode === 'workspace_ambiguous'
1464
+ && digest(linkedStore) === linkedStoreBefore && fs.readdirSync(linkedOutputTarget).length === 0);
1465
+ }
1466
+ fs.writeFileSync(path.join(linkedOutputRoot, '.leerness/HARNESS_VERSION'), require('../package.json').version);
1467
+
1436
1468
  result = run(linkedOutputRoot, ['preview', 'mockup', 'P-0001', '--json']);
1437
1469
  parsed = json(result);
1438
1470
  check('preview mockup rejects an outward linked output directory before writing outside the project',
@@ -1442,7 +1474,6 @@ try {
1442
1474
  && !fs.existsSync(path.join(linkedOutputTarget, 'P-0001-mockup.html')),
1443
1475
  result);
1444
1476
 
1445
- const previewServe = require('../lib/preview-serve');
1446
1477
  const linkedBuild = previewServe.buildServeWorkspace(linkedOutputRoot, { id: 'P-0001', mockupPath: 'mock.html' });
1447
1478
  check('buildServeWorkspace rejects an outward linked output directory before writing its temporary HTML',
1448
1479
  linkedBuild.ok === false
@@ -901,7 +901,10 @@ async function main() {
901
901
  makeDirLink(cacheTarget, cacheLink);
902
902
  const cacheLinkAcquire = runLease(cacheLinkRoot, ['acquire', 'x.js', '--session', alpha, '--ttl', '300']);
903
903
  check('lease cache symlinks or junctions are rejected before writing tracked project paths',
904
- cacheLinkAcquire.status === 1 && parseJson(cacheLinkAcquire)?.code === 'cache_symlink'
904
+ // Runtime admission now rejects the unsafe fixed descriptor parent before
905
+ // CLI bookkeeping or the lease-specific validator can run.
906
+ cacheLinkAcquire.status === 1 && parseJson(cacheLinkAcquire)?.code === 'runtime_layout_incompatible'
907
+ && parseJson(cacheLinkAcquire)?.error.includes('(layout_linked)')
905
908
  && !fs.existsSync(path.join(cacheTarget, 'file-leases.json')),
906
909
  cacheLinkAcquire.stdout || cacheLinkAcquire.stderr);
907
910
 
@@ -1054,7 +1057,7 @@ async function main() {
1054
1057
  && PU._requiredTier('npx --yes leerness lease acquire reports/release publish/status.js') === 'safe-write');
1055
1058
 
1056
1059
  check('command catalog and both help locales advertise exact-file lease',
1057
- catalog.status === 0 && catalogJson?.totalCommands === 100
1060
+ catalog.status === 0 && catalogJson?.totalCommands === rows.length
1058
1061
  && rows.some(row => /^lease acquire\|release\|list\|check\b/.test(row.cmd))
1059
1062
  && /lease acquire\|release\|list\|check/.test(helpEn.stdout)
1060
1063
  && /leerness lease acquire\|release\|list\|check/.test(helpKo.stdout),
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ // Release-only negative controls. Supply an existing installed v1.36.186 package;
4
+ // this probe never downloads a client or changes that installation.
5
+ const assert = require('assert');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+ const cp = require('child_process');
10
+ const crypto = require('crypto');
11
+ const legacy = fs.realpathSync(process.argv[2] || assert.fail('Supply installed leerness@1.36.186 package directory'));
12
+ assert.strictEqual(JSON.parse(fs.readFileSync(path.join(legacy, 'package.json'))).version, '1.36.186');
13
+ const parent = fs.realpathSync(os.tmpdir());
14
+ const arena = fs.mkdtempSync(path.join(parent, 'leerness-legacy-runtime-negative-'));
15
+ const sourceFiles = ['package.json', 'bin/leerness.js', 'lib/io.js'].map(name => path.join(legacy, name));
16
+ function identity(file) {
17
+ return [String(fs.statSync(file, { bigint: true }).mtimeNs),
18
+ crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')];
19
+ }
20
+ const beforeSources = sourceFiles.map(identity);
21
+ const env = { ...process.env, LEERNESS_OFFLINE: '1', LEERNESS_INTERNAL: '1',
22
+ LEERNESS_NO_STALE_CHECK: '1', LEERNESS_NO_AUTOCHCP: '1', LEERNESS_NO_PROMPT: '1' };
23
+ for (const key of Object.keys(env)) if (/^GIT_|^LEERNESS_(SESSION_ID|WORKSPACE_DIR|MCP_)/i.test(key)) delete env[key];
24
+ const future = JSON.stringify({ schema: 'leerness.runtime-layout/v1', schemaVersion: 1,
25
+ scope: 'project-local', generation: 1, layout: 'legacy', requiredWriterProtocol: 2 });
26
+ function fixture(name) {
27
+ const root = path.join(arena, name);
28
+ fs.mkdirSync(path.join(root, '.leerness/cache'), { recursive: true });
29
+ fs.writeFileSync(path.join(root, '.leerness/HARNESS_VERSION'), '1.36.186\n');
30
+ return root;
31
+ }
32
+ function layout(root) {
33
+ const file = path.join(root, '.leerness/cache/state-layout.json');
34
+ fs.writeFileSync(file, future); return file;
35
+ }
36
+ try {
37
+ const root = fixture('old-cli'), descriptor = layout(root);
38
+ const stamp = identity(descriptor);
39
+ const old = cp.spawnSync(process.execPath, [path.join(legacy, 'bin/leerness.js'),
40
+ 'state', 'start', '--path', root, '--goal', 'isolated negative control', '--json'],
41
+ { cwd: root, env, encoding: 'utf8', timeout: 120000 });
42
+ assert.strictEqual(old.status, 0, old.stderr + old.stdout);
43
+ assert(fs.existsSync(path.join(root, '.leerness/state.json')), 'old CLI must demonstrate the known missing guard');
44
+ assert.deepStrictEqual(identity(descriptor), stamp);
45
+ console.log('PASS negative control: v1.36.186 CLI does not honor the new descriptor');
46
+
47
+ const retained = require(path.join(legacy, 'lib/io.js')).writeUtf8;
48
+ const longLived = fixture('already-loaded-writer');
49
+ layout(longLived);
50
+ const output = path.join(longLived, '.leerness/cache/retained.txt');
51
+ retained(output, 'old writer remains unguarded');
52
+ assert.strictEqual(fs.readFileSync(output, 'utf8'), 'old writer remains unguarded');
53
+ console.log('PASS negative control: already-loaded legacy writer remains unguarded');
54
+
55
+ const currentRoot = fixture('current-cli'), currentDescriptor = layout(currentRoot);
56
+ const currentStamp = identity(currentDescriptor);
57
+ const current = cp.spawnSync(process.execPath, [path.resolve(__dirname, '../bin/leerness.js'),
58
+ 'state', 'start', '--path', currentRoot, '--goal', 'new writer control', '--json'],
59
+ { cwd: currentRoot, env, encoding: 'utf8', timeout: 120000 });
60
+ assert.strictEqual(current.status, 1, current.stderr + current.stdout);
61
+ assert.strictEqual(JSON.parse(current.stdout).code, 'runtime_layout_incompatible');
62
+ assert(!fs.existsSync(path.join(currentRoot, '.leerness/state.json')));
63
+ assert.deepStrictEqual(identity(currentDescriptor), currentStamp);
64
+ console.log('PASS current writer refuses the same incompatible layout');
65
+ assert.deepStrictEqual(sourceFiles.map(identity), beforeSources);
66
+ console.log('legacy runtime negative controls: 3/3 PASS; installed source unchanged');
67
+ } finally {
68
+ assert.strictEqual(fs.realpathSync(arena), arena);
69
+ assert.strictEqual(path.dirname(arena), parent);
70
+ assert(path.basename(arena).startsWith('leerness-legacy-runtime-negative-'));
71
+ fs.rmSync(arena, { recursive: true, force: true });
72
+ }
@@ -489,6 +489,9 @@ try {
489
489
  // Security + critical drift must still run session recovery in the same pass.
490
490
  const combinedDir = path.join(tempRoot, 'drift-security-critical');
491
491
  fs.mkdirSync(path.join(combinedDir, '.leerness'), { recursive: true });
492
+ // The DI fixture writes a synthetic usage.json, unlike the real cache path.
493
+ // Establish workspace ownership before exercising its recovery sequence.
494
+ fs.writeFileSync(path.join(combinedDir, '.leerness', 'HARNESS_VERSION'), 'fixture\n');
492
495
  fs.writeFileSync(path.join(combinedDir, '.env'), 'TOKEN=value\n');
493
496
  fs.writeFileSync(path.join(combinedDir, '.gitignore'), 'node_modules/\n');
494
497
  fs.writeFileSync(path.join(combinedDir, '.leerness', 'session-handoff.md'), 'Last generated: 2020-01-01T00:00:00.000Z\n');
@@ -686,7 +686,10 @@ test('linked execution-ledger parents are rejected before any external target is
686
686
  assert.strictEqual(history.code, 'ledger_parent_linked');
687
687
  assert.throws(
688
688
  () => appendExecutionEvent(dir, { event: 'execution.started', taskId: 'T-LINK' }),
689
- error => error && error.code === 'ledger_parent_linked',
689
+ // Direct reads retain their ledger diagnostic; writer admission rejects
690
+ // the linked workspace before entering the append implementation.
691
+ error => error && error.code === 'E_RUNTIME_LAYOUT_INCOMPATIBLE'
692
+ && error.reasonCode === 'workspace_dir_symlink',
690
693
  );
691
694
  assert.strictEqual(fs.existsSync(path.join(outside, 'execution-ledger.jsonl')), false);
692
695
  assert.deepStrictEqual(fs.readFileSync(path.join(outside, 'sentinel.txt')), before);
@@ -750,14 +753,59 @@ test('execution-ledger reads and appends bind validation to the opened inode', (
750
753
  });
751
754
 
752
755
  test('dangling execution-ledger symlinks are rejected without creating their target', () => {
753
- const base = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ledger-dangling-'));
756
+ const temp = fs.realpathSync.native(os.tmpdir());
757
+ const base = fs.mkdtempSync(path.join(temp, 'leerness-ledger-dangling-'));
754
758
  const dir = path.join(base, 'project');
755
759
  const ledger = path.join(dir, '.leerness', 'execution-ledger.jsonl');
756
760
  const missingTarget = path.join(base, 'outside-missing.jsonl');
761
+ let linkType = 'file';
762
+ const snapshot = () => {
763
+ const rows = [];
764
+ const visit = (file, relative) => {
765
+ const stat = fs.lstatSync(file, { bigint: true });
766
+ const row = [relative, String(stat.mode), String(stat.size), String(stat.mtimeNs)];
767
+ if (stat.isSymbolicLink()) rows.push([...row, 'link', fs.readlinkSync(file)]);
768
+ else if (stat.isDirectory()) {
769
+ rows.push([...row, 'directory']);
770
+ for (const name of fs.readdirSync(file).sort()) visit(path.join(file, name), `${relative}/${name}`);
771
+ } else {
772
+ assert.ok(stat.isFile(), `unexpected fixture file kind: ${relative}`);
773
+ rows.push([...row, 'file', fs.readFileSync(file).toString('base64')]);
774
+ }
775
+ };
776
+ visit(base, '.');
777
+ return rows;
778
+ };
757
779
  try {
758
780
  fs.mkdirSync(path.dirname(ledger), { recursive: true });
759
- try { fs.symlinkSync(missingTarget, ledger, 'file'); }
760
- catch { return; }
781
+ fs.writeFileSync(path.join(base, 'outside-sentinel.txt'), 'unchanged\n', 'utf8');
782
+ assert.strictEqual(fs.existsSync(missingTarget), false);
783
+ // Keep native file-symlink coverage wherever available, including Windows CI.
784
+ // Only Windows permission failures fall back to the same lstat guard via a junction.
785
+ try { fs.symlinkSync(missingTarget, ledger, linkType); }
786
+ catch (error) {
787
+ if (process.platform !== 'win32' || !['EPERM', 'EACCES'].includes(error.code)) throw error;
788
+ linkType = 'junction';
789
+ fs.symlinkSync(missingTarget, ledger, linkType);
790
+ }
791
+ assert.ok(fs.lstatSync(ledger).isSymbolicLink());
792
+ assert.strictEqual(fs.existsSync(missingTarget), false);
793
+
794
+ // A standalone store containing a link is not an accepted workspace. The first
795
+ // writer refuses admission before the ledger domain can inspect or create its target.
796
+ const standaloneBefore = snapshot();
797
+ assert.throws(
798
+ () => appendExecutionEvent(dir, { event: 'execution.started', taskId: 'T-DANGLING' }),
799
+ error => error && error.code === 'E_RUNTIME_LAYOUT_INCOMPATIBLE'
800
+ && error.reasonCode === 'workspace_ambiguous',
801
+ );
802
+ assert.strictEqual(fs.existsSync(missingTarget), false);
803
+ assert.deepStrictEqual(snapshot(), standaloneBefore);
804
+
805
+ // A normal initialized marker admits the workspace, preserving the original
806
+ // read/append domain-link checks instead of replacing them with the admission error.
807
+ fs.writeFileSync(path.join(dir, '.leerness', 'HARNESS_VERSION'), require('../package.json').version + '\n', 'utf8');
808
+ const initializedBefore = snapshot();
761
809
  const history = readExecutionEvents(dir, 20);
762
810
  assert.strictEqual(history.ok, false);
763
811
  assert.strictEqual(history.code, 'ledger_not_regular_file');
@@ -766,9 +814,18 @@ test('dangling execution-ledger symlinks are rejected without creating their tar
766
814
  error => error && error.code === 'ledger_not_regular_file',
767
815
  );
768
816
  assert.strictEqual(fs.existsSync(missingTarget), false);
817
+ assert.deepStrictEqual(snapshot(), initializedBefore);
818
+ console.log(' DANGLING_LEDGER_BOUNDARIES ' + JSON.stringify({
819
+ platform: process.platform, linkType, casesRun: 2, skipped: 0,
820
+ standalone: 'workspace_ambiguous', initialized: 'ledger_not_regular_file', unchanged: true,
821
+ }));
769
822
  } finally {
770
823
  try { fs.rmSync(ledger, { force: true }); } catch {}
771
- fs.rmSync(base, { recursive: true, force: true });
824
+ const actual = fs.realpathSync.native(base);
825
+ assert.strictEqual(actual, base);
826
+ assert.strictEqual(path.dirname(actual), temp);
827
+ assert.match(path.basename(actual), /^leerness-ledger-dangling-[A-Za-z0-9]{6}$/);
828
+ fs.rmSync(actual, { recursive: true, force: true });
772
829
  }
773
830
  });
774
831
 
@@ -1999,7 +2056,11 @@ try {
1999
2056
  fs.mkdirSync(ledger);
2000
2057
  const result = run(['agents', 'history', '--path', fixture, '--json'], { env });
2001
2058
  assert.strictEqual(result.status, 1, result.stderr || result.stdout);
2002
- assert.strictEqual(json(result).code, 'ledger_not_regular_file');
2059
+ // The CLI has startup bookkeeping, so ambiguous standalone ownership is
2060
+ // refused first. The read-only store API still reports its exact reason.
2061
+ assert.strictEqual(json(result).code, 'runtime_layout_incompatible');
2062
+ assert.ok(json(result).error.includes('(workspace_ambiguous)'));
2063
+ assert.strictEqual(readExecutionEvents(fixture, 20).code, 'ledger_not_regular_file');
2003
2064
  assert.ok(fs.statSync(ledger).isDirectory());
2004
2065
  });
2005
2066
  } finally {