leerness 1.36.298 → 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.
- package/CHANGELOG.md +33 -0
- package/README.md +4 -4
- package/bin/leerness.js +14 -4
- package/lib/copy-tree.js +5 -1
- package/lib/runtime-writes.js +24 -4
- package/package.json +6 -4
- package/scripts/bugfix-areas-probe.js +1 -1
- package/scripts/copy-tree-probe.js +17 -0
- package/scripts/done-status-probe.js +1 -1
- package/scripts/e2e-lived-in-harness.js +2 -2
- package/scripts/fix-evidence-language-probe.js +1 -1
- package/scripts/i18n-next-cluster-probe.js +1 -1
- package/scripts/memory-archive-check-probe.js +1 -1
- package/scripts/memory-archive-retention-probe.js +2 -2
- package/scripts/memory-arena-cleanup-probe.js +106 -0
- package/scripts/memory-checked-input-probe.js +1 -1
- package/scripts/memory-guard-order-probe.js +1 -1
- package/scripts/memory-input-preflight-probe.js +1 -1
- package/scripts/memory-legacy-retention-probe.js +2 -2
- package/scripts/memory-migration-test-support.js +40 -2
- package/scripts/memory-output-publication-probe.js +1 -1
- package/scripts/memory-policy-root-spelling-probe.js +16 -6
- package/scripts/memory-projection-retention-probe.js +1 -1
- package/scripts/memory-recovery-external-probe.js +68 -1
- package/scripts/memory-recovery-receipt-probe.js +1 -1
- package/scripts/memory-restore-canonical-probe.js +1 -1
- package/scripts/memory-utf8-admission-probe.js +1 -1
- package/scripts/path-setup-powershell-probe.js +15 -0
- package/scripts/release-bump-probe.js +1 -1
- package/scripts/review-request-labels-probe.js +1 -1
- package/scripts/review-request-signals-probe.js +1 -1
- package/scripts/skill-archive-probe.js +71 -0
- package/scripts/store-values-probe.js +9 -4
- package/scripts/task-sync-roundtrip-probe.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.299 - 2026-10-12
|
|
4
|
+
|
|
5
|
+
- Fixed: on Windows, `leerness skill publish` made a `.zip` instead of the `.tgz` when Git for Windows' GNU tar came first on PATH, as it does in Git Bash and in agent shells built on it (T-0352).
|
|
6
|
+
- Cause: the archive step gave `tar -czf` the absolute output path, and GNU tar reads the drive letter `C:` as a remote host ("Cannot connect to C:"). The step failed, and the Compress-Archive fallback made a `.zip`.
|
|
7
|
+
- The archive is now named relative to the `.leerness` folder, with forward slashes (`skills-publish-tarball/leerness-skills-<version>.tgz`). Windows' own tar (bsdtar) accepts that form too.
|
|
8
|
+
- Fixed: on Windows, `leerness path-setup --apply` could take a different folder for the npm bin folder and not add it (T-0353).
|
|
9
|
+
- Cause: it compared Path entries with PowerShell's `-contains`. That comparison uses the culture and ignores characters such as a soft hyphen (U+00AD), so an entry that differed from the bin folder only by such a character counted as present. Measured in Windows PowerShell 5.1 and PowerShell 7.
|
|
10
|
+
- Entries are now compared by ordinal value, letter case aside.
|
|
11
|
+
- Fixed: the recovery bundle that a memory write saves to the temp folder after a runtime-layout failure could be written inside the project (T-0349). The check that keeps it out compared the project and the temp folder as spelled.
|
|
12
|
+
- When the temp folder was named by an 8.3 short name (GitHub's Windows runners use `C:\Users\RUNNER~1\...`) or reached through a link, it could lie inside the project while its spelling did not.
|
|
13
|
+
- The check also took a folder inside the project whose name starts with two dots, such as `..temp`, for a folder outside it.
|
|
14
|
+
- The check now also compares their real paths, and only a first path part of exactly `..` counts as outside. A temp folder that does not resolve is refused, and so is a project path that fails to resolve for any reason other than being gone. A project path that no longer exists, because the project was renamed or removed during the operation, is compared as spelled only, as before, so its recovery is still saved.
|
|
15
|
+
- The bundle is now written below the temp folder's real path, the one that was checked, and the recovery folder it reports is named by that path. A temp link retargeted into the project after the check can no longer carry the bundle there.
|
|
16
|
+
- Tests (T-0347, T-0349, T-0352, T-0353, T-0354, T-0355):
|
|
17
|
+
- `scripts/skill-archive-probe.js` (`npm run test:skill-archive`, in `test`, `test:core` and `test:fast`) asks the archive step for the `.tgz` with each tar the machine has first on PATH, and checks that tar made it, with the expected entries. Here that is Windows' bsdtar and Git for Windows' GNU tar. With the old absolute path it passes 2 of 6.
|
|
18
|
+
- `scripts/memory-recovery-external-probe.js` (21 tests) adds nine. The old check fails eight of them:
|
|
19
|
+
- A temp folder inside the project, named through a link outside the project or by its 8.3 short name, must be refused before any recovery work. So must a folder inside the project whose name starts with two dots, named directly or through a link.
|
|
20
|
+
- A temp folder that does not resolve must be refused, also when the project path is gone. So must a project path that fails to resolve with an error other than "not found" (simulated). With the old check, an unresolvable temp folder failed the recovery later, with ENOENT.
|
|
21
|
+
- A temp link retargeted into the project while the check runs (simulated) must not carry the bundle into the project.
|
|
22
|
+
- A project renamed away during the operation must still get its recovery outside it. The old check passes this one.
|
|
23
|
+
- `scripts/path-setup-powershell-probe.js` (19 checks):
|
|
24
|
+
- An entry that differs from the bin folder by a soft hyphen must not count as present. The old script fails it.
|
|
25
|
+
- Every registry run now requires the parser check first. With a command injected into the script, no registry run happened. With that requirement removed, the injected command ran.
|
|
26
|
+
- `scripts/memory-policy-root-spelling-probe.js`: the MCP checks and the resolve check use the first non-canonical spelling the machine has. Before, they always used the lowercase drive letter, which a UNC path does not have. They print SKIP when there is none.
|
|
27
|
+
- The shared support of the memory probes (`scripts/memory-migration-test-support.js`) now removes its temp arena when every test passed, and keeps it when one failed. Links inside the arena are removed as links first, so their targets are left alone. An arena, or a folder in it, that was replaced with a link is neither walked nor removed. This holds also when the replacement happens while `finish()` runs: each folder must still resolve inside the arena just before it is listed and before a link in it is removed. A process that kept replacing folders during `finish()` could still race those checks; `finish()` runs after the tests.
|
|
28
|
+
- Before, every run left one arena (1,206 were in this machine's temp folder on 2026-10-11).
|
|
29
|
+
- `scripts/memory-arena-cleanup-probe.js` (`npm run test:memory-arena-cleanup`, in `test`, `test:core` and `test:fast`) checks five cases with links to folders outside the arena, three of them with a replacement before or during `finish()`. On the old support all five fail: the removal case because nothing is removed, the failing-run and replace-before cases only on the note that `finish()` now prints, and the two replacement-during cases because the old `finish()` never looks at the arena, so the replacement never happens.
|
|
30
|
+
- The 24 `fs.cpSync` copies in 21 test files now use `lib/copy-tree.js` (T-0347). On Windows with Node 22.23.3, `fs.cpSync` ends the process with an access violation when a path has non-ASCII letters, as the temp folder does for a Windows user name in Korean.
|
|
31
|
+
- Measured with the temp folder set to `...\AppData\Local\Temp\임시한글` on Node 22.23.3: on the old code all 21 files crashed (Segmentation fault, exit 139 in Git Bash). With the change, 20 passed.
|
|
32
|
+
- The 21st, `scripts/store-values-probe.js`, failed one language check, which read the Hangul in the temp path as Korean text. Its language checks now mask the test folder's path first, and it passes.
|
|
33
|
+
- On Windows, `lib/copy-tree.js` now resolves a relative directory link against the copy's own folder, so the copied link names its sibling in the copy. Before, Node resolved the junction target against the process's working folder. On POSIX the relative link is copied as written and names the same sibling. `fs.cpSync` instead points such a link back into the source tree.
|
|
34
|
+
- `scripts/copy-tree-probe.js` checks this where links can be made. A Windows machine without the symlink privilege skips it.
|
|
35
|
+
|
|
3
36
|
## 1.36.298 - 2026-10-11
|
|
4
37
|
|
|
5
38
|
- Fixed: on Windows, `leerness path-setup --apply` never registered the npm bin folder in the user PATH (T-0351). This was so from 1.9.254.
|
package/README.md
CHANGED
|
@@ -195,7 +195,7 @@ MIT
|
|
|
195
195
|
<!-- leerness:project-readme:start -->
|
|
196
196
|
## Leerness Project Harness
|
|
197
197
|
|
|
198
|
-
이 프로젝트는 Leerness v1.36.
|
|
198
|
+
이 프로젝트는 Leerness v1.36.299 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
|
|
199
199
|
|
|
200
200
|
### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
|
|
201
201
|
|
|
@@ -249,7 +249,7 @@ leerness memory restore decision <date|title>
|
|
|
249
249
|
|
|
250
250
|
### MCP server (외부 AI 통합)
|
|
251
251
|
|
|
252
|
-
Leerness v1.36.
|
|
252
|
+
Leerness v1.36.299는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **102개 도구**를 노출:
|
|
253
253
|
|
|
254
254
|
```jsonc
|
|
255
255
|
// 카테고리별
|
|
@@ -270,7 +270,7 @@ Leerness v1.36.298는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
|
|
|
270
270
|
`<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
|
|
271
271
|
1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
|
|
272
272
|
|
|
273
|
-
현재 사용 중인 하네스 버전: **1.36.
|
|
273
|
+
현재 사용 중인 하네스 버전: **1.36.299**. 공개 릴리스 여부는 공개 저장소의 태그와 배포 기록에서 별도로 확인합니다.
|
|
274
274
|
|
|
275
275
|
### 성능 가이드
|
|
276
276
|
|
|
@@ -308,7 +308,7 @@ leerness release pack --close --auto-main-push
|
|
|
308
308
|
- `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
|
|
309
309
|
- `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
|
|
310
310
|
|
|
311
|
-
Last synced by Leerness v1.36.
|
|
311
|
+
Last synced by Leerness v1.36.299: 2026-09-30
|
|
312
312
|
<!-- leerness:project-readme:end -->
|
|
313
313
|
GitHub 로그인 계정 확인용 서버 어댑터: [설정·연결·승인과의 구분](docs/github-login.md). 실제 앱 등록/브라우저 세션 연결과 대상별 승인은 후속입니다.
|
|
314
314
|
|
package/bin/leerness.js
CHANGED
|
@@ -115,7 +115,7 @@ const {
|
|
|
115
115
|
migrateLegacyWorkspace,
|
|
116
116
|
} = require('../lib/workspace-dir');
|
|
117
117
|
|
|
118
|
-
const VERSION = '1.36.
|
|
118
|
+
const VERSION = '1.36.299';
|
|
119
119
|
|
|
120
120
|
// MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
|
|
121
121
|
// 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
|
|
@@ -3998,7 +3998,7 @@ function _pathDiagnose() {
|
|
|
3998
3998
|
// do to the user Path (review round 1): the read expands %VAR% entries with this process's values, and the write
|
|
3999
3999
|
// stores REG_SZ, so a REG_EXPAND_SZ Path (the Windows default, e.g. %USERPROFILE%\...\WindowsApps) would lose its
|
|
4000
4000
|
// references. The script reads the raw value and its registry type and writes both back. An entry counts as present
|
|
4001
|
-
// only as written (letter case aside): expanding %VAR% here would use this shell's variables, which new terminals may
|
|
4001
|
+
// only as written (ordinal, letter case aside): expanding %VAR% here would use this shell's variables, which new terminals may
|
|
4002
4002
|
// not have, and could report EXISTS for a directory they cannot reach (review round 3; the owner chose this). The
|
|
4003
4003
|
// cost is a harmless second entry when the Path already names the directory through a variable. Deleting a variable
|
|
4004
4004
|
// that does not exist through SetEnvironmentVariable(..., 'User') writes nothing but sends the WM_SETTINGCHANGE that
|
|
@@ -4020,7 +4020,10 @@ function _winPathPsScript() {
|
|
|
4020
4020
|
+ "$kind = [Microsoft.Win32.RegistryValueKind]::ExpandString; "
|
|
4021
4021
|
+ "if ($names -contains 'Path') { $kind = $k.GetValueKind('Path') }; "
|
|
4022
4022
|
+ "if ($kind -ne [Microsoft.Win32.RegistryValueKind]::String -and $kind -ne [Microsoft.Win32.RegistryValueKind]::ExpandString) { throw ('the user Path has registry type ' + $kind) }; "
|
|
4023
|
-
|
|
4023
|
+
// T-0353: ordinal, case-insensitive. -contains compares with the culture, which ignores characters such as a
|
|
4024
|
+
// soft hyphen (U+00AD), so 'C:\np<U+00AD>m' counted as 'C:\npm' (measured in Windows PowerShell 5.1 and 7).
|
|
4025
|
+
+ "$found = $false; foreach ($p in $raw.Split(';')) { if ([string]::Equals($p, $b, [StringComparison]::OrdinalIgnoreCase)) { $found = $true } }; "
|
|
4026
|
+
+ "if ($found) { [Console]::Out.WriteLine('EXISTS') } "
|
|
4024
4027
|
+ "else { $nu = if ($raw -eq '') { $b } else { $raw.TrimEnd(';') + ';' + $b }; "
|
|
4025
4028
|
+ "$k.SetValue('Path', $nu, $kind); "
|
|
4026
4029
|
+ "[Environment]::SetEnvironmentVariable('LEERNESS_PATH_SETUP_NOTIFY', $null, 'User'); "
|
|
@@ -26493,7 +26496,12 @@ function _createArchive(cwd, sourceDir, outPath) {
|
|
|
26493
26496
|
// 1) tar.gz (POSIX 환경에서 가장 안정)
|
|
26494
26497
|
if (/\.(tgz|tar\.gz)$/i.test(outPath)) {
|
|
26495
26498
|
tried.push('tar');
|
|
26496
|
-
|
|
26499
|
+
// T-0352: the archive is named relative to cwd, with forward slashes. Git for Windows' GNU tar, first on PATH in
|
|
26500
|
+
// Git Bash, reads the drive letter of an absolute Windows path as a remote host ("Cannot connect to C:") and
|
|
26501
|
+
// failed, so the .zip fallback ran instead. bsdtar accepts either form. A path on another drive stays absolute.
|
|
26502
|
+
const relativeOut = path.relative(cwd, outPath);
|
|
26503
|
+
const tarOut = relativeOut && !path.isAbsolute(relativeOut) ? relativeOut.split(path.sep).join('/') : outPath;
|
|
26504
|
+
const r = _spawnPortableArgvSync('tar', ['-czf', tarOut, sourceDir], {
|
|
26497
26505
|
encoding: 'utf8', timeout: 30000, shell: false, cwd
|
|
26498
26506
|
});
|
|
26499
26507
|
if (r.status === 0 && exists(outPath)) return { ok: true, path: outPath, method: 'tar', tried };
|
|
@@ -34146,6 +34154,8 @@ module.exports = {
|
|
|
34146
34154
|
_cliMutationClass, _mustRemainReadOnly, _runtimeProjectRoot,
|
|
34147
34155
|
_npmGlobalBin, _dirInPath, _leernessResolvable, _pathDiagnose, _registerPath,
|
|
34148
34156
|
_winPathPsScript, _unixPathBlock, pathSetupCmd,
|
|
34157
|
+
// T-0352: the skill publish archive chain (tar, then the zip fallbacks), for scripts/skill-archive-probe.js.
|
|
34158
|
+
_createArchive,
|
|
34149
34159
|
_isSecretKey, compareVer, parseHarnessVersion,
|
|
34150
34160
|
// 1.9.257: CJK 인코딩 분류 (UR-0014 계열) 순수 함수 — 단위 테스트
|
|
34151
34161
|
_classifyCJK, _riskLabel,
|
package/lib/copy-tree.js
CHANGED
|
@@ -12,10 +12,14 @@ const path = require('path');
|
|
|
12
12
|
function copyTreeSync(src, dst) {
|
|
13
13
|
const stat = fs.lstatSync(src);
|
|
14
14
|
if (stat.isSymbolicLink()) {
|
|
15
|
-
|
|
15
|
+
let target = fs.readlinkSync(src);
|
|
16
16
|
let type;
|
|
17
17
|
if (process.platform === 'win32') {
|
|
18
18
|
try { type = fs.statSync(src).isDirectory() ? 'junction' : 'file'; } catch { type = 'file'; }
|
|
19
|
+
// T-0347: a junction's target must be absolute, and Node resolves a relative one against the process cwd.
|
|
20
|
+
// A relative directory link is resolved against the copy's own folder, so it still names the place it named
|
|
21
|
+
// relative to where it now is (fs.cpSync instead points it back into the source tree).
|
|
22
|
+
if (type === 'junction' && !path.isAbsolute(target)) target = path.resolve(path.dirname(dst), target);
|
|
19
23
|
}
|
|
20
24
|
fs.symlinkSync(target, dst, type);
|
|
21
25
|
return;
|
package/lib/runtime-writes.js
CHANGED
|
@@ -159,13 +159,33 @@ function assertExternalRecoveryAllowed(root) {
|
|
|
159
159
|
}
|
|
160
160
|
const project = path.resolve(root);
|
|
161
161
|
const base = path.resolve(os.tmpdir());
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
// T-0349: compared as spelled and as real paths. A temp folder reached through a link, or named by an 8.3 short
|
|
163
|
+
// name (GitHub's Windows runners give C:\Users\RUNNER~1\...), can lie inside the project while its spelling does
|
|
164
|
+
// not, and the recovery bundle was then written into the project it must stay out of. A temp folder that cannot
|
|
165
|
+
// be resolved counts as unsafe. A project path that no longer exists (ENOENT or ENOTDIR: renamed or removed during
|
|
166
|
+
// the operation, one way this latch is reached) has nothing at it to lie inside, so only its spelling is compared;
|
|
167
|
+
// any other failure to resolve it counts as unsafe.
|
|
168
|
+
const unsafe = () => {
|
|
164
169
|
const error = new Error(`External recovery temp must stay outside project root: ${base}`);
|
|
165
170
|
error.code = 'E_RECOVERY_TEMP_UNSAFE';
|
|
166
171
|
throw error;
|
|
167
|
-
}
|
|
168
|
-
|
|
172
|
+
};
|
|
173
|
+
// Outside means a first component of exactly '..' (a child folder may be named '..temp', review round 1).
|
|
174
|
+
const inside = (from, to) => {
|
|
175
|
+
const relative = path.relative(from, to);
|
|
176
|
+
return !(relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative));
|
|
177
|
+
};
|
|
178
|
+
const real = p => { try { return { path: fs.realpathSync.native(p) }; } catch (error) { return { error }; } };
|
|
179
|
+
const realBase = real(base), realProject = real(project);
|
|
180
|
+
if (!realBase.path) unsafe();
|
|
181
|
+
const missing = !realProject.path && ['ENOENT', 'ENOTDIR'].includes(realProject.error && realProject.error.code);
|
|
182
|
+
if (!realProject.path && !missing) unsafe();
|
|
183
|
+
if (inside(project, base)) unsafe();
|
|
184
|
+
if (realProject.path && inside(realProject.path, realBase.path)) unsafe();
|
|
185
|
+
// The bundle goes below the real path that was checked, not below the alias: a temp link retargeted into the project
|
|
186
|
+
// after this check would otherwise carry the bundle there (review round 2). A real folder replaced while the
|
|
187
|
+
// recovery runs is not covered, as for every path-based write here.
|
|
188
|
+
return realBase.path;
|
|
169
189
|
}
|
|
170
190
|
|
|
171
191
|
// Streaming recovery uses the same latch and external-only destination. The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.299",
|
|
4
4
|
"description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"leerness",
|
|
@@ -48,15 +48,15 @@
|
|
|
48
48
|
"LICENSE"
|
|
49
49
|
],
|
|
50
50
|
"scripts": {
|
|
51
|
-
"test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && node ./scripts/e2e-lived-in-harness.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:github-login && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-publication && npm run test:ui-review-target && npm run test:ui-review-authorization && npm run test:ui-review-authority-host && npm run test:ui-review-authority-floor && npm run test:ui-review-proof-use && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:agent-chat-stream && npm run test:encoding-diagnostics && npm run test:managed-text && npm run test:plan-text && npm run test:session-close-text && npm run test:brief-text && npm run test:generation-text && npm run test:install-text && npm run test:authoring-language && npm run test:context-reading && npm run test:rule-text && npm run test:mode-text && npm run test:handoff-text && npm run test:audit-text && npm run test:audit-readme-text && npm run test:audit-env-text && npm run test:feature-graph-text && npm run test:env-sync-text && npm run test:teams-text && npm run test:reminder-text && npm run test:handoff-history-text && npm run test:env-snapshot-text && npm run test:context-current && npm run test:review-feedback && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && node ./scripts/review-control-probe.js && npm run test:ui-review-run && npm run test:rule-verification",
|
|
52
|
-
"test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && node ./scripts/e2e-lived-in-harness.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:github-login && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-publication-smoke && npm run test:ui-review-target-smoke && npm run test:ui-review-authorization-smoke && npm run test:ui-review-authority-host-smoke && npm run test:ui-review-authority-floor-smoke && npm run test:ui-review-proof-use-smoke && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:review-feedback && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && node ./scripts/review-snapshot-transport-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && npm run test:ui-review-run && npm run test:rule-verification",
|
|
51
|
+
"test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && node ./scripts/e2e-lived-in-harness.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && npm run test:skill-archive && npm run test:memory-arena-cleanup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:github-login && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-publication && npm run test:ui-review-target && npm run test:ui-review-authorization && npm run test:ui-review-authority-host && npm run test:ui-review-authority-floor && npm run test:ui-review-proof-use && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:agent-chat-stream && npm run test:encoding-diagnostics && npm run test:managed-text && npm run test:plan-text && npm run test:session-close-text && npm run test:brief-text && npm run test:generation-text && npm run test:install-text && npm run test:authoring-language && npm run test:context-reading && npm run test:rule-text && npm run test:mode-text && npm run test:handoff-text && npm run test:audit-text && npm run test:audit-readme-text && npm run test:audit-env-text && npm run test:feature-graph-text && npm run test:env-sync-text && npm run test:teams-text && npm run test:reminder-text && npm run test:handoff-history-text && npm run test:env-snapshot-text && npm run test:context-current && npm run test:review-feedback && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && node ./scripts/review-control-probe.js && npm run test:ui-review-run && npm run test:rule-verification",
|
|
52
|
+
"test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && node ./scripts/e2e-lived-in-harness.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && npm run test:skill-archive && npm run test:memory-arena-cleanup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:github-login && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-publication-smoke && npm run test:ui-review-target-smoke && npm run test:ui-review-authorization-smoke && npm run test:ui-review-authority-host-smoke && npm run test:ui-review-authority-floor-smoke && npm run test:ui-review-proof-use-smoke && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:review-feedback && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && node ./scripts/review-snapshot-transport-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && npm run test:ui-review-run && npm run test:rule-verification",
|
|
53
53
|
"test:commands": "npm run test:hooks && npm run test:e2e-observation && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/command-flags-probe.js && node ./scripts/dead-flags-probe.js && node ./scripts/free-text-args-probe.js && node ./scripts/title-args-probe.js && node ./scripts/false-claim-probe.js && node ./scripts/claim-sweep-git-probe.js && node ./scripts/claim-runtime-cache-probe.js && node ./scripts/runtime-cost-probe.js && node ./scripts/secret-scan-scope-probe.js && node ./scripts/handoff-preservation-probe.js && node ./scripts/next-action-suggestion-probe.js && node ./scripts/api-skill-match-probe.js && node ./scripts/crud-check-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-request-labels-probe.js && node ./scripts/review-request-signals-probe.js && node ./scripts/store-values-probe.js && node ./scripts/review-snapshot-transport-probe.js && node ./scripts/bugfix-areas-probe.js && node ./scripts/done-status-probe.js && node ./scripts/fix-evidence-language-probe.js && node ./scripts/decision-fence-offset-probe.js && node ./scripts/task-sync-roundtrip-probe.js && node ./scripts/tracker-write-language-probe.js && node ./scripts/write-path-language-probe.js && node ./scripts/write-primitive-language-probe.js && node ./scripts/dry-run-refusal-probe.js",
|
|
54
54
|
"test:e2e-observation": "node ./scripts/e2e-temp-scope-probe.js && node ./scripts/e2e-child-diagnostics-probe.js",
|
|
55
55
|
"test:next-actions": "node ./scripts/next-action-suggestion-probe.js && node ./scripts/api-skill-match-probe.js",
|
|
56
56
|
"test:installed": "npm run test:installed-lifecycle && node ./scripts/installed-cleanroom-probe.js",
|
|
57
57
|
"test:installed-lifecycle": "node ./scripts/installed-handoff-lifecycle-probe.js",
|
|
58
58
|
"test:handoff": "node ./scripts/handoff-readonly-probe.js",
|
|
59
|
-
"test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./scripts/false-claim-probe.js && node ./scripts/smoke.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && node ./scripts/review-snapshot-transport-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && npm run test:rule-verification",
|
|
59
|
+
"test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && npm run test:skill-archive && npm run test:memory-arena-cleanup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./scripts/false-claim-probe.js && node ./scripts/smoke.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && node ./scripts/review-snapshot-transport-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && npm run test:rule-verification",
|
|
60
60
|
"test:false-claims": "node ./scripts/false-claim-probe.js",
|
|
61
61
|
"test:claim-sweep": "node ./scripts/claim-sweep-git-probe.js",
|
|
62
62
|
"test:claim-runtime": "node ./scripts/claim-runtime-cache-probe.js",
|
|
@@ -141,6 +141,8 @@
|
|
|
141
141
|
"test:lock-heartbeat": "node ./scripts/lock-heartbeat-probe.js",
|
|
142
142
|
"test:temp-cleanup": "node ./scripts/temp-cleanup-probe.js && node ./scripts/copy-tree-probe.js",
|
|
143
143
|
"test:path-setup": "node ./scripts/path-setup-powershell-probe.js",
|
|
144
|
+
"test:skill-archive": "node ./scripts/skill-archive-probe.js",
|
|
145
|
+
"test:memory-arena-cleanup": "node ./scripts/memory-arena-cleanup-probe.js",
|
|
144
146
|
"test:ui-review-publication": "node ./scripts/ui-review-publication-probe.js",
|
|
145
147
|
"test:ui-review-publication-smoke": "node ./scripts/ui-review-publication-probe.js --smoke",
|
|
146
148
|
"test:ui-review-target": "node ./scripts/ui-review-target-probe.js",
|
|
@@ -41,7 +41,7 @@ const seed = lang => {
|
|
|
41
41
|
return (seeds[lang] = dir);
|
|
42
42
|
};
|
|
43
43
|
let copies = 0;
|
|
44
|
-
const fresh = (lang = 'ko') => { const dir = path.join(arena, 'p' + (++copies));
|
|
44
|
+
const fresh = (lang = 'ko') => { const dir = path.join(arena, 'p' + (++copies)); require('../lib/copy-tree').copyTreeSync(seed(lang), dir); return dir; };
|
|
45
45
|
const runIn = (cwd, args, runEnv = env) => cp.spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8', timeout: 300000, windowsHide: true, env: runEnv });
|
|
46
46
|
const show = r => `exit ${r.status}\n${String(r.stdout || '').slice(0, 1200)}${String(r.stderr || '').slice(0, 600)}`;
|
|
47
47
|
const json = (r, label) => { const out = String(r.stdout || ''); try { return JSON.parse(out.slice(out.indexOf('{'))); } catch { throw new Error(label + ': not JSON — ' + show(r)); } };
|
|
@@ -50,6 +50,23 @@ try {
|
|
|
50
50
|
assert.throws(() => copyTreeSync(from, to), error => error.code === 'EEXIST');
|
|
51
51
|
assert.strictEqual(fs.readFileSync(path.join(to, 'bytes.bin'), 'utf8'), 'kept');
|
|
52
52
|
});
|
|
53
|
+
// T-0347: a relative directory link must still name its sibling inside the copy. On Windows the copy is a junction,
|
|
54
|
+
// whose target must be absolute; Node resolved the relative one against the process cwd.
|
|
55
|
+
{
|
|
56
|
+
const from = path.join(arena, 'src-relative-link'), to = path.join(arena, 'dst-relative-link');
|
|
57
|
+
fs.mkdirSync(path.join(from, 'sibling'), { recursive: true });
|
|
58
|
+
fs.writeFileSync(path.join(from, 'sibling', 'mark.txt'), 'source');
|
|
59
|
+
let made = true;
|
|
60
|
+
try { fs.symlinkSync('sibling', path.join(from, 'link'), 'dir'); }
|
|
61
|
+
catch (error) { if (error.code !== 'EPERM') throw error; made = false; }
|
|
62
|
+
if (!made) console.log('SKIP a relative directory link (creating one needs the symlink privilege here)');
|
|
63
|
+
else test('a relative directory link names its sibling inside the copy', () => {
|
|
64
|
+
copyTreeSync(from, to);
|
|
65
|
+
fs.writeFileSync(path.join(to, 'sibling', 'mark.txt'), 'copy');
|
|
66
|
+
assert.strictEqual(fs.readFileSync(path.join(to, 'link', 'mark.txt'), 'utf8'), 'copy');
|
|
67
|
+
assert.strictEqual(fs.realpathSync.native(path.join(to, 'link')), fs.realpathSync.native(path.join(to, 'sibling')));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
53
70
|
test('a missing source is an error, not an empty copy', () => {
|
|
54
71
|
assert.throws(() => copyTreeSync(path.join(arena, 'missing'), path.join(arena, 'dst-missing')), error => error.code === 'ENOENT');
|
|
55
72
|
assert.ok(!fs.existsSync(path.join(arena, 'dst-missing')));
|
|
@@ -34,7 +34,7 @@ const seed = () => {
|
|
|
34
34
|
return (seedDir = dir);
|
|
35
35
|
};
|
|
36
36
|
let copies = 0;
|
|
37
|
-
const fresh = () => { const dir = path.join(arena, 'p' + (++copies));
|
|
37
|
+
const fresh = () => { const dir = path.join(arena, 'p' + (++copies)); require('../lib/copy-tree').copyTreeSync(seed(), dir); return dir; };
|
|
38
38
|
const runIn = (cwd, args) => cp.spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8', timeout: 300000, windowsHide: true, env });
|
|
39
39
|
const show = r => `exit ${r.status}\n${String(r.stdout || '').slice(0, 1200)}${String(r.stderr || '').slice(0, 600)}`;
|
|
40
40
|
const json = (r, label) => { const out = String(r.stdout || ''); try { return JSON.parse(out.slice(out.indexOf('{'))); } catch { throw new Error(label + ': not JSON — ' + show(r)); } };
|
|
@@ -225,7 +225,7 @@ if (require.main === module) {
|
|
|
225
225
|
]) {
|
|
226
226
|
const copy = path.join(root, 'copy-' + (++n));
|
|
227
227
|
covered.push(what);
|
|
228
|
-
|
|
228
|
+
require('../lib/copy-tree').copyTreeSync(dir, copy);
|
|
229
229
|
t(`fixture: ${what} changed alone is the one reported missing (#${n})`, () => {
|
|
230
230
|
change(copy);
|
|
231
231
|
assert.deepStrictEqual(livedInMissing(copy).map(m => m.split(' (')[0]), [what]);
|
|
@@ -235,7 +235,7 @@ if (require.main === module) {
|
|
|
235
235
|
// Control for the whole-id match: the id in Markdown emphasis still names the task.
|
|
236
236
|
t('fixture: a handoff naming the task as __id__ still counts', () => {
|
|
237
237
|
const copy = path.join(root, 'copy-emphasis');
|
|
238
|
-
|
|
238
|
+
require('../lib/copy-tree').copyTreeSync(dir, copy);
|
|
239
239
|
edit(copy, 'session-handoff.md', built.ids.task1, '__' + built.ids.task1 + '__');
|
|
240
240
|
assert.deepStrictEqual(livedInMissing(copy), []);
|
|
241
241
|
});
|
|
@@ -31,7 +31,7 @@ const seed = lang => {
|
|
|
31
31
|
return (seeds[lang] = dir);
|
|
32
32
|
};
|
|
33
33
|
let copies = 0;
|
|
34
|
-
const fresh = lang => { const dir = path.join(arena, 'p' + (++copies));
|
|
34
|
+
const fresh = lang => { const dir = path.join(arena, 'p' + (++copies)); require('../lib/copy-tree').copyTreeSync(seed(lang), dir); return dir; };
|
|
35
35
|
const runIn = (cwd, args, extraEnv = {}) => cp.spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8', timeout: 300000, windowsHide: true, env: { ...env, ...extraEnv } });
|
|
36
36
|
const show = r => `exit ${r.status}\n${String(r.stdout || '').slice(0, 1500)}${String(r.stderr || '').slice(0, 600)}`;
|
|
37
37
|
const addDone = (dir, title, evidence) => {
|
|
@@ -140,7 +140,7 @@ function normalizedJson(label, result) {
|
|
|
140
140
|
function cloneProjectState(target) {
|
|
141
141
|
fs.mkdirSync(target, { recursive: true });
|
|
142
142
|
fs.copyFileSync(path.join(project, 'package.json'), path.join(target, 'package.json'));
|
|
143
|
-
|
|
143
|
+
require('../lib/copy-tree').copyTreeSync(path.join(project, '.leerness'), path.join(target, '.leerness'));
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
const surfaces = [
|
|
@@ -37,7 +37,7 @@ const code = r => { try { return JSON.parse(r.stdout).code; } catch { return '(n
|
|
|
37
37
|
// A project holding `rows` (canonical JSON and its generated Markdown), dropped by `target`: the drop is the product's.
|
|
38
38
|
function dropped(surface, rows, target) {
|
|
39
39
|
const project = path.join(temp, `case-${++sequence}`);
|
|
40
|
-
|
|
40
|
+
require('../lib/copy-tree').copyTreeSync(seed, project);
|
|
41
41
|
const hd = path.join(project, '.leerness');
|
|
42
42
|
const f = { project, surface, json: path.join(hd, `${surface}.json`), md: path.join(hd, `${surface}.md`), archive: path.join(hd, `${surface}.archive.md`) };
|
|
43
43
|
fs.writeFileSync(f.json, JSON.stringify(rows, null, 2) + '\n');
|
|
@@ -40,7 +40,7 @@ function run(project, args) {
|
|
|
40
40
|
function success(r) { assert.strictEqual(r.status, 0, String(r.stdout || r.stderr).slice(-1000)); }
|
|
41
41
|
function fixture(surface, entry) {
|
|
42
42
|
const project = path.join(temp, `case-${++sequence}`);
|
|
43
|
-
|
|
43
|
+
require('../lib/copy-tree').copyTreeSync(seed, project);
|
|
44
44
|
const hd = path.join(project, '.leerness');
|
|
45
45
|
const json = path.join(hd, `${surface}.json`);
|
|
46
46
|
const md = path.join(hd, `${surface}.md`);
|
|
@@ -176,7 +176,7 @@ try {
|
|
|
176
176
|
});
|
|
177
177
|
check('a lesson saved with surrounding spaces through the CLI drops and restores exactly', () => {
|
|
178
178
|
const project = path.join(temp, `case-${++sequence}`);
|
|
179
|
-
|
|
179
|
+
require('../lib/copy-tree').copyTreeSync(seed, project);
|
|
180
180
|
const json = path.join(project, '.leerness', 'lessons.json');
|
|
181
181
|
success(run(project, ['lesson', 'save', ' existing ']));
|
|
182
182
|
const saved = JSON.parse(fs.readFileSync(json, 'utf8'));
|
|
@@ -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));
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`);
|
|
@@ -52,11 +52,18 @@ function restoreMcp(spelled, s) {
|
|
|
52
52
|
assert(!r.error && !r.signal, r.error && r.error.message); assert.equal(r.status,0,r.stderr);
|
|
53
53
|
return JSON.parse(r.stdout.trim().split(/\r?\n/)[0]);
|
|
54
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
|
+
}
|
|
55
62
|
|
|
56
63
|
for (const s of ['decisions','lessons']) {
|
|
57
64
|
let expected = null;
|
|
58
65
|
for (const [name, spell] of spellings) {
|
|
59
|
-
const p = make(s), spelled = spell
|
|
66
|
+
const p = make(s), spelled = trySpell(spell, p);
|
|
60
67
|
if (spelled === null) { console.log(`SKIP ${s}: policy restore through ${name} (this volume or path gives no such spelling)`); continue; }
|
|
61
68
|
test(`${s}: policy restore through ${name} matches the restore through the real path`, () => {
|
|
62
69
|
if (name !== 'as created') assert.notStrictEqual(spelled, p, 'the spelling must differ from the real path');
|
|
@@ -68,10 +75,11 @@ for (const s of ['decisions','lessons']) {
|
|
|
68
75
|
if (expected === null) expected = after; else assert.deepEqual(after, expected);
|
|
69
76
|
});
|
|
70
77
|
}
|
|
71
|
-
const
|
|
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
|
|
72
81
|
test(`${s}: MCP policy restore through ${mcpName}`, () => {
|
|
73
|
-
const p =
|
|
74
|
-
assert(spelled && spelled !== p, 'the MCP case needs a non-canonical spelling');
|
|
82
|
+
const p = pm;
|
|
75
83
|
const reply = restoreMcp(spelled, s);
|
|
76
84
|
assert(reply.result, JSON.stringify(reply)); assert.equal(reply.result.isError, false, JSON.stringify(reply));
|
|
77
85
|
assert.deepEqual(state(p, s).rows.map(entry => entry.title || entry.text), ['base','revive']);
|
|
@@ -80,9 +88,11 @@ for (const s of ['decisions','lessons']) {
|
|
|
80
88
|
// The capture names files under the given spelling only after that spelling's workspace resolves to the resolved
|
|
81
89
|
// one. Make it resolve elsewhere (as a swapped link would between the two resolutions) and the capture must refuse
|
|
82
90
|
// before reading a file; without the check the files of the other folder would be measured as this project's.
|
|
83
|
-
|
|
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', () => {
|
|
84
94
|
const { inspectMemoryFootprint } = require('../lib/memory-footprint');
|
|
85
|
-
const p =
|
|
95
|
+
const p = resolveCheckProject, other = make('decisions'), spelled = resolveCheckAlternate[1];
|
|
86
96
|
const limits = JSON.stringify(options().observation);
|
|
87
97
|
assert.equal(inspectMemoryFootprint(spelled,'decisions',limits).ok, true, 'control: the spelling itself is accepted');
|
|
88
98
|
const workspace = path.join(spelled,'.leerness'), same = (a, b) => win ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
@@ -127,8 +127,14 @@ function hookRegistration(options = {}) {
|
|
|
127
127
|
};
|
|
128
128
|
return seen;
|
|
129
129
|
}
|
|
130
|
+
// T-0353: the parser check is a precondition for every run below. A script with a command in it (an appended
|
|
131
|
+
// `reg.exe add HKCU\Environment ...`, say) would pass the text rewrite, and the final Path hash would only see the
|
|
132
|
+
// damage afterwards; so when the script does not parse to zero commands, no registry run happens at all.
|
|
133
|
+
let commandCheck = null;
|
|
134
|
+
const scriptIsCommandFree = () => (commandCheck === null ? (commandCheck = commandsAndErrors(SCRIPT)) : commandCheck) === '0|0';
|
|
130
135
|
// Runs _registerPath with the spawn hook. Returns its result, what the child printed, and the throwaway Path after.
|
|
131
136
|
function register(bin, preset, options = {}) {
|
|
137
|
+
assert(scriptIsCommandFree(), 'the PATH script must parse to zero commands before any registry run: ' + commandCheck);
|
|
132
138
|
presetTestPath(...preset);
|
|
133
139
|
const seen = hookRegistration(options);
|
|
134
140
|
let result;
|
|
@@ -193,6 +199,15 @@ try {
|
|
|
193
199
|
assert.strictEqual(r.after, after);
|
|
194
200
|
assert.deepStrictEqual({ ok: r.result.ok, method: r.result.method }, { ok: true, method: word === 'ADDED' ? 'powershell-user-path' : 'already' });
|
|
195
201
|
});
|
|
202
|
+
// T-0353: the match is ordinal. PowerShell's -eq and -contains compare with the culture and ignore a soft hyphen,
|
|
203
|
+
// so an entry that only looks like the bin path counted as present.
|
|
204
|
+
test('an entry that differs from the bin path by an ignorable character is not taken as present', () => {
|
|
205
|
+
const softHyphen = String.fromCharCode(0xad);
|
|
206
|
+
const lookalike = BIN.slice(0, -1) + softHyphen + BIN.slice(-1);
|
|
207
|
+
const r = register(BIN, ['String', 'C:\\a;' + lookalike]);
|
|
208
|
+
assert.strictEqual(r.seen[0].stdout.toString(), 'ADDED\r\n', r.seen[0].stderr);
|
|
209
|
+
assert(r.after.startsWith('String=C:\\a;') && r.after.endsWith(';' + BIN), r.after);
|
|
210
|
+
});
|
|
196
211
|
test('a bin path with PowerShell syntax is added as text', () => {
|
|
197
212
|
const hostile = "C:\\safe $(Write-Output INJECTED) `tick 'q' t\\";
|
|
198
213
|
const r = register(hostile, ['String', 'C:\\a']);
|
|
@@ -20,7 +20,7 @@ function versionFromSource(file) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function copyFixture(root) {
|
|
23
|
-
for (const dir of ['bin', 'lib'])
|
|
23
|
+
for (const dir of ['bin', 'lib']) require('../lib/copy-tree').copyTreeSync(path.join(sourceRoot, dir), path.join(root, dir));
|
|
24
24
|
for (const file of ['package.json', 'README.md']) fs.copyFileSync(path.join(sourceRoot, file), path.join(root, file));
|
|
25
25
|
fs.mkdirSync(path.join(root, '.leerness'), { recursive: true });
|
|
26
26
|
const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
|
|
@@ -38,7 +38,7 @@ const project = () => {
|
|
|
38
38
|
fs.mkdirSync(path.join(template, '.leerness', 'skills'));
|
|
39
39
|
}
|
|
40
40
|
const dir = path.join(arena, 'p' + (++seq));
|
|
41
|
-
|
|
41
|
+
require('../lib/copy-tree').copyTreeSync(template, dir);
|
|
42
42
|
return dir;
|
|
43
43
|
};
|
|
44
44
|
const skill = (dir, id, displayNameKo, capabilities) => {
|
|
@@ -44,7 +44,7 @@ const project = () => {
|
|
|
44
44
|
fs.mkdirSync(path.join(template, '.leerness', 'skills'));
|
|
45
45
|
}
|
|
46
46
|
const dir = path.join(arena, 'p' + (++seq));
|
|
47
|
-
|
|
47
|
+
require('../lib/copy-tree').copyTreeSync(template, dir);
|
|
48
48
|
return dir;
|
|
49
49
|
};
|
|
50
50
|
const store = (dir, name) => path.join(dir, '.leerness', name);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// T-0352: the archive step of `leerness skill publish --bundle-only` (_createArchive in bin/leerness.js).
|
|
3
|
+
// It ran `tar -czf <absolute path>`. Git for Windows' GNU tar, first on PATH in Git Bash and in agent shells built on
|
|
4
|
+
// it, reads the drive letter of an absolute Windows path as a remote host and failed, so the .zip fallback ran
|
|
5
|
+
// instead of the .tgz. The e2e check accepted either. This probe asks for the .tgz with each tar the machine has
|
|
6
|
+
// first on PATH and checks that tar made it, with the expected entries.
|
|
7
|
+
const assert = require('assert');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const zlib = require('zlib');
|
|
12
|
+
const { _createArchive } = require('../bin/leerness.js');
|
|
13
|
+
|
|
14
|
+
let passed = 0, failed = 0;
|
|
15
|
+
function test(name, fn) {
|
|
16
|
+
try { fn(); passed++; console.log('PASS ' + name); }
|
|
17
|
+
catch (error) { failed++; console.error('FAIL ' + name + ': ' + (error && error.stack || error)); }
|
|
18
|
+
}
|
|
19
|
+
// Entry names of a .tgz, read with zlib and the ustar header layout (name at 0, prefix at 345).
|
|
20
|
+
function tgzEntries(file) {
|
|
21
|
+
const tar = zlib.gunzipSync(fs.readFileSync(file)), names = [];
|
|
22
|
+
for (let at = 0; at + 512 <= tar.length;) {
|
|
23
|
+
const header = tar.subarray(at, at + 512);
|
|
24
|
+
if (header.every(byte => byte === 0)) break;
|
|
25
|
+
const text = (from, length) => header.subarray(from, from + length).toString('utf8').replace(/\0[\s\S]*$/, '');
|
|
26
|
+
const name = text(0, 100), prefix = text(345, 155), size = parseInt(text(124, 12).trim() || '0', 8);
|
|
27
|
+
const type = text(156, 1);
|
|
28
|
+
if (type !== 'x' && type !== 'g' && type !== 'L') names.push((prefix ? prefix + '/' : '') + name);
|
|
29
|
+
at += 512 + Math.ceil(size / 512) * 512;
|
|
30
|
+
}
|
|
31
|
+
return names.map(n => n.replace(/\/$/, '')).sort();
|
|
32
|
+
}
|
|
33
|
+
const arena = fs.mkdtempSync(path.join(fs.realpathSync.native(os.tmpdir()), 'leerness-skill-archive-'));
|
|
34
|
+
function project(name) {
|
|
35
|
+
const root = path.join(arena, name), source = path.join(root, '.leerness', 'skills-publish');
|
|
36
|
+
fs.mkdirSync(path.join(source, 'skill-a'), { recursive: true });
|
|
37
|
+
fs.writeFileSync(path.join(source, 'manifest.json'), '{"skills":1}\n');
|
|
38
|
+
fs.writeFileSync(path.join(source, 'skill-a', 'SKILL.md'), '# Skill A\n');
|
|
39
|
+
return path.join(root, '.leerness');
|
|
40
|
+
}
|
|
41
|
+
const tars = [['the tar found on PATH', null]];
|
|
42
|
+
if (process.platform === 'win32') {
|
|
43
|
+
const system32 = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32');
|
|
44
|
+
if (fs.existsSync(path.join(system32, 'tar.exe'))) tars.push(["System32's bsdtar first on PATH", system32]);
|
|
45
|
+
const gitUsrBin = path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'usr', 'bin');
|
|
46
|
+
if (fs.existsSync(path.join(gitUsrBin, 'tar.exe'))) tars.push(["Git for Windows' GNU tar first on PATH", gitUsrBin]);
|
|
47
|
+
else console.log("SKIP Git for Windows' GNU tar first on PATH (not installed)");
|
|
48
|
+
}
|
|
49
|
+
const expected = ['skills-publish', 'skills-publish/manifest.json', 'skills-publish/skill-a', 'skills-publish/skill-a/SKILL.md'];
|
|
50
|
+
const originalPath = process.env.PATH;
|
|
51
|
+
try {
|
|
52
|
+
for (const [name, dir] of tars) {
|
|
53
|
+
for (const [where, out] of [['below the working folder', cwd => path.join(cwd, 'skills-publish-tarball', 'bundle.tgz')],
|
|
54
|
+
['beside the working folder', cwd => path.join(path.dirname(cwd), 'out', 'bundle.tgz')]]) {
|
|
55
|
+
test(`.tgz with ${name}, archive ${where}`, () => {
|
|
56
|
+
const cwd = project(`p-${passed + failed}`), file = out(cwd);
|
|
57
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
58
|
+
if (dir) process.env.PATH = dir + path.delimiter + originalPath;
|
|
59
|
+
let made;
|
|
60
|
+
try { made = _createArchive(cwd, 'skills-publish', file); } finally { process.env.PATH = originalPath; }
|
|
61
|
+
assert.deepStrictEqual({ ok: made.ok, method: made.method, path: made.path }, { ok: true, method: 'tar', path: file }, JSON.stringify(made));
|
|
62
|
+
assert.deepStrictEqual(tgzEntries(file), expected);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
process.env.PATH = originalPath;
|
|
68
|
+
fs.rmSync(arena, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
console.log(`skill archive probe: ${passed}/${passed + failed} passed (${process.platform})`);
|
|
71
|
+
process.exitCode = failed ? 1 : 0;
|
|
@@ -31,6 +31,11 @@ const filter = process.argv.includes('--match') ? process.argv[process.argv.inde
|
|
|
31
31
|
const test = (name, fn) => { if (filter && !name.includes(filter)) return; try { fn(); passed++; console.log('PASS ' + name); } catch (e) { failed++; console.error('FAIL ' + name + ': ' + (e && e.stack || e)); } };
|
|
32
32
|
const run = (dir, args, extra = {}) => cp.spawnSync(process.execPath, [CLI, ...args], { cwd: dir, encoding: 'utf8', timeout: 300000, windowsHide: true, env: { ...env, ...extra } });
|
|
33
33
|
const both = r => String(r.stdout || '') + String(r.stderr || '');
|
|
34
|
+
// T-0347: the language checks read messages with the arena path masked. Below a temp folder with Hangul in its path
|
|
35
|
+
// (a Windows user name in Korean), a path in an English message is not Korean text, and in a Korean one it is not
|
|
36
|
+
// proof of Korean text.
|
|
37
|
+
const arenaSpellings = [...new Set([arena, fs.realpathSync.native(arena)])];
|
|
38
|
+
const prose = text => arenaSpellings.reduce((s, a) => s.split(a).join('<arena>'), String(text));
|
|
34
39
|
const must = (r, label) => { assert.ifError(r.error); assert.strictEqual(r.status, 0, label + ' exit ' + r.status + '\n' + both(r)); return String(r.stdout || ''); };
|
|
35
40
|
const json = (r, label) => { const out = must(r, label); try { return JSON.parse(out); } catch (e) { throw new Error(label + ': stdout is not one JSON object\n' + out.slice(0, 800)); } };
|
|
36
41
|
// init takes most of the run time, so it runs once; each test works on its own copy, with no installed skills.
|
|
@@ -44,7 +49,7 @@ const project = () => {
|
|
|
44
49
|
fs.mkdirSync(path.join(template, '.leerness', 'skills'));
|
|
45
50
|
}
|
|
46
51
|
const dir = path.join(arena, 'p' + (++seq));
|
|
47
|
-
|
|
52
|
+
require('../lib/copy-tree').copyTreeSync(template, dir);
|
|
48
53
|
return dir;
|
|
49
54
|
};
|
|
50
55
|
const store = (dir, ...parts) => path.join(dir, '.leerness', ...parts);
|
|
@@ -128,7 +133,7 @@ test('array fields that are not arrays do not crash skill commands, and skill le
|
|
|
128
133
|
const r = run(dir, ['skill', 'learn', 'str-caps', '--capability', 'zebra import', '--language', lang, '--path', dir]);
|
|
129
134
|
assert.strictEqual(r.status, 1, 'learn into a non-list must fail\n' + both(r));
|
|
130
135
|
assert.ok(/capabilities/.test(both(r)) && !JS_ERROR.test(both(r)), both(r));
|
|
131
|
-
assert.ok(lang === 'en' ? !/[가-힣]/.test(both(r)) : /[가-힣]/.test(both(r)), lang + ' message: ' + both(r));
|
|
136
|
+
assert.ok(lang === 'en' ? !/[가-힣]/.test(prose(both(r))) : /[가-힣]/.test(prose(both(r))), lang + ' message: ' + both(r));
|
|
132
137
|
}
|
|
133
138
|
assert.strictEqual(fs.readFileSync(store(dir, 'skills', 'str-caps', 'skill.json'), 'utf8'), before);
|
|
134
139
|
// skill use counts from zero when there is no count (it saved NaN as null); a count that is not a number and optimizations
|
|
@@ -295,9 +300,9 @@ test('malformed platform-constraints.json entries are reported as an invalid sto
|
|
|
295
300
|
const dir = project();
|
|
296
301
|
constraints(dir, shapes['aliases is a string']);
|
|
297
302
|
const add = run(dir, ['constraints', 'add', 'acme', '--constraint', 'cost:x', '--language', 'en', '--path', dir]);
|
|
298
|
-
assert.ok(add.status === 1 && /damaged/.test(both(add)) && !/[가-힣]/.test(both(add)), both(add));
|
|
303
|
+
assert.ok(add.status === 1 && /damaged/.test(both(add)) && !/[가-힣]/.test(prose(both(add))), both(add));
|
|
299
304
|
const note = json(run(dir, ['constraints', 'list', '--json', '--language', 'en', '--path', dir]), 'list --json en').note;
|
|
300
|
-
assert.ok(/damaged/.test(note) && !/[가-힣]/.test(note), note);
|
|
305
|
+
assert.ok(/damaged/.test(note) && !/[가-힣]/.test(prose(note)), note);
|
|
301
306
|
});
|
|
302
307
|
|
|
303
308
|
test('the graph page reads skill fields as text', () => {
|
|
@@ -37,7 +37,7 @@ const seed = () => {
|
|
|
37
37
|
return (seedDir = dir);
|
|
38
38
|
};
|
|
39
39
|
let copies = 0;
|
|
40
|
-
const fresh = () => { const dir = path.join(arena, 'p' + (++copies));
|
|
40
|
+
const fresh = () => { const dir = path.join(arena, 'p' + (++copies)); require('../lib/copy-tree').copyTreeSync(seed(), dir); return dir; };
|
|
41
41
|
const runIn = (cwd, args) => cp.spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8', timeout: 300000, windowsHide: true, env });
|
|
42
42
|
const show = r => `exit ${r.status}\n${String(r.stdout || '').slice(0, 1500)}${String(r.stderr || '').slice(0, 600)}`;
|
|
43
43
|
const jsonOf = (r, label) => { const out = String(r.stdout || ''); try { return JSON.parse(out.slice(out.indexOf('{'))); } catch { throw new Error(label + ': no JSON — ' + show(r)); } };
|