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.
- package/CHANGELOG.md +17 -0
- package/README.ko.md +5 -0
- package/README.md +14 -4
- package/bin/leerness.js +291 -57
- package/docs/e2e-observation-contract.md +51 -0
- package/docs/runtime-compatibility-api.md +183 -0
- package/docs/state-paths-api.md +92 -0
- package/docs/state-scopes.md +266 -0
- package/docs/worktree-runtime-migration.md +278 -0
- package/lib/claims-baseline.js +1 -1
- package/lib/file-leases.js +2 -2
- package/lib/git.js +64 -15
- package/lib/io.js +9 -3
- package/lib/migrate.js +1 -1
- package/lib/preview-serve.js +5 -1
- package/lib/pure-utils.js +20 -0
- package/lib/role-fallback.js +3 -3
- package/lib/role-store.js +2 -2
- package/lib/runtime-layout.js +395 -0
- package/lib/runtime-writes.js +166 -0
- package/lib/session-close.js +1 -1
- package/lib/state-git.js +138 -0
- package/lib/state-inspect.js +39 -0
- package/lib/state-inventory.js +69 -0
- package/lib/state-paths.js +59 -0
- package/lib/workspace-dir.js +34 -10
- package/package.json +10 -6
- package/scripts/command-flags-probe.js +1 -1
- package/scripts/dead-flags-probe.js +1 -1
- package/scripts/e2e-child-diagnostics-probe.js +143 -0
- package/scripts/e2e-child-diagnostics.js +33 -0
- package/scripts/e2e-temp-scope-probe.js +127 -0
- package/scripts/e2e.js +108 -27
- package/scripts/encoding-selftest-probe.js +222 -0
- package/scripts/false-claim-probe.js +34 -3
- package/scripts/file-lease-probe.js +5 -2
- package/scripts/legacy-runtime-negative-probe.js +72 -0
- package/scripts/mutation-integrity-probe.js +3 -0
- package/scripts/role-fallback-probe.js +67 -6
- package/scripts/runtime-admission-probe.js +282 -0
- package/scripts/runtime-git-write-probe.js +116 -0
- package/scripts/runtime-layout-probe.js +1198 -0
- package/scripts/runtime-repl-probe.js +271 -0
- package/scripts/runtime-replacement-probe.js +190 -0
- package/scripts/runtime-write-probe.js +458 -0
- package/scripts/selftest-cleanup-probe.js +168 -0
- package/scripts/state-inspect-cli-probe.js +209 -0
- package/scripts/state-scopes-probe.js +590 -0
- package/scripts/workspace-dir-lock-order-probe.js +310 -35
- package/scripts/workspace-dir-migration-probe.js +17 -2
- 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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
760
|
-
|
|
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.
|
|
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
|
-
|
|
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 {
|