cursedbelt-core 1.0.0 → 1.2.0
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/dist/core/file-tree/fileTreeModel.d.ts +18 -0
- package/dist/core/file-tree/fileTreeModel.js +24 -0
- package/package.json +2 -2
- package/src/core/file-tree/fileTreeModel.spec.ts +23 -0
- package/src/core/file-tree/fileTreeModel.ts +33 -0
- package/src/leafSubpathsImportNothing.spec.ts +15 -3
- package/src/shippedFilesAreTracked.spec.ts +69 -0
|
@@ -259,6 +259,24 @@ export declare function resolveKeyboardMove({ nodes, rows, row, direction, locke
|
|
|
259
259
|
* Empty for no target — a refusal has its own words and this must never carry a second "no".
|
|
260
260
|
*/
|
|
261
261
|
export declare function describeDrop(nodes: readonly FileTreeNode[], drop: FileTreeDrop | null): string;
|
|
262
|
+
/** The consumer's own words for the 🔒 access mark, per state. See {@link accessMarkCopy}. */
|
|
263
|
+
export interface AccessMarkCopy {
|
|
264
|
+
private?: string;
|
|
265
|
+
locked?: string;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* What the access mark's tooltip SAYS for a given access state.
|
|
269
|
+
*
|
|
270
|
+
* 🔴 The defaults speak collections' vocabulary — "hidden from a locked page" — because
|
|
271
|
+
* that is the app the mark was built for. The mark itself is generic and the words are
|
|
272
|
+
* not: the vault's protected folders wear the same padlock meaning "everything in here
|
|
273
|
+
* needs the master password", and a claim of hiddenness would be false there (the rows
|
|
274
|
+
* are visible; their SECRETS are sealed). So the words are an argument with a default,
|
|
275
|
+
* resolved here rather than inline, where they can be tested without simulating a hover.
|
|
276
|
+
*
|
|
277
|
+
* Empty string for `normal` — a row with no mark has nothing to say.
|
|
278
|
+
*/
|
|
279
|
+
export declare function accessMarkCopy(access: FileTreeAccess | undefined, copy?: AccessMarkCopy): string;
|
|
262
280
|
/**
|
|
263
281
|
* The next row whose name starts with `query`, searching from `fromIndex` and wrapping.
|
|
264
282
|
*
|
|
@@ -268,6 +268,30 @@ export function describeDrop(nodes, drop) {
|
|
|
268
268
|
const where = drop.kind === 'before' ? 'Above' : 'Below';
|
|
269
269
|
return sibling ? `${where} ${sibling.name}` : `${where} this row`;
|
|
270
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* What the access mark's tooltip SAYS for a given access state.
|
|
273
|
+
*
|
|
274
|
+
* 🔴 The defaults speak collections' vocabulary — "hidden from a locked page" — because
|
|
275
|
+
* that is the app the mark was built for. The mark itself is generic and the words are
|
|
276
|
+
* not: the vault's protected folders wear the same padlock meaning "everything in here
|
|
277
|
+
* needs the master password", and a claim of hiddenness would be false there (the rows
|
|
278
|
+
* are visible; their SECRETS are sealed). So the words are an argument with a default,
|
|
279
|
+
* resolved here rather than inline, where they can be tested without simulating a hover.
|
|
280
|
+
*
|
|
281
|
+
* Empty string for `normal` — a row with no mark has nothing to say.
|
|
282
|
+
*/
|
|
283
|
+
export function accessMarkCopy(access, copy) {
|
|
284
|
+
if (access === 'locked')
|
|
285
|
+
return copy?.locked ?? 'Hidden — unlock to see what is in here';
|
|
286
|
+
if (access === 'private') {
|
|
287
|
+
// Deliberately "this or what is in it": a consumer sets `private` both for a row that
|
|
288
|
+
// is itself private and for a public folder holding private children (collections does
|
|
289
|
+
// exactly that), and a wording that claimed only the first would be false on half the
|
|
290
|
+
// rows it marks.
|
|
291
|
+
return copy?.private ?? 'Private — this, or something in it, is hidden from a locked page';
|
|
292
|
+
}
|
|
293
|
+
return '';
|
|
294
|
+
}
|
|
271
295
|
/**
|
|
272
296
|
* The next row whose name starts with `query`, searching from `fromIndex` and wrapping.
|
|
273
297
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Framework-agnostic core of the cursedbelt split \u2014 isomorphic domain logic. cwip below it, cursedbelt-server and cursedbelt (react) above it.",
|
|
@@ -356,7 +356,7 @@
|
|
|
356
356
|
},
|
|
357
357
|
"dependencies": {
|
|
358
358
|
"@leeoniya/ufuzzy": "^1.0.19",
|
|
359
|
-
"cwip": "^
|
|
359
|
+
"cwip": "^4.1.0"
|
|
360
360
|
},
|
|
361
361
|
"peerDependencies": {
|
|
362
362
|
"zod": "^4.4.0"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import {
|
|
3
|
+
accessMarkCopy,
|
|
3
4
|
descendantIdsOf,
|
|
4
5
|
type FileTreeDrop,
|
|
5
6
|
type FileTreeNode,
|
|
@@ -460,3 +461,25 @@ describe('describeDrop', () => {
|
|
|
460
461
|
expect(describeDrop(TREE, { kind: 'into', parentId: 'gone' })).toBe('Into this folder');
|
|
461
462
|
});
|
|
462
463
|
});
|
|
464
|
+
|
|
465
|
+
describe('accessMarkCopy', () => {
|
|
466
|
+
test('defaults speak collections — hiddenness, per state', () => {
|
|
467
|
+
expect(accessMarkCopy('locked')).toBe('Hidden — unlock to see what is in here');
|
|
468
|
+
expect(accessMarkCopy('private')).toContain('hidden from a locked page');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test('🔴 a consumer with its own privacy vocabulary overrides the words', () => {
|
|
472
|
+
// The vault's protected folders wear the same padlock meaning "needs the master
|
|
473
|
+
// password" — a claim of hiddenness would be FALSE there (rows visible, secrets
|
|
474
|
+
// sealed), which is why the words are an argument and not a constant.
|
|
475
|
+
const copy = { private: 'Protected — everything in here needs the master password' };
|
|
476
|
+
expect(accessMarkCopy('private', copy)).toBe(copy.private);
|
|
477
|
+
// An override for one state leaves the other's default standing.
|
|
478
|
+
expect(accessMarkCopy('locked', copy)).toBe('Hidden — unlock to see what is in here');
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('normal (and absent) access has nothing to say', () => {
|
|
482
|
+
expect(accessMarkCopy('normal')).toBe('');
|
|
483
|
+
expect(accessMarkCopy(undefined)).toBe('');
|
|
484
|
+
});
|
|
485
|
+
});
|
|
@@ -464,6 +464,39 @@ export function describeDrop(nodes: readonly FileTreeNode[], drop: FileTreeDrop
|
|
|
464
464
|
return sibling ? `${where} ${sibling.name}` : `${where} this row`;
|
|
465
465
|
}
|
|
466
466
|
|
|
467
|
+
/** The consumer's own words for the 🔒 access mark, per state. See {@link accessMarkCopy}. */
|
|
468
|
+
export interface AccessMarkCopy {
|
|
469
|
+
private?: string;
|
|
470
|
+
locked?: string;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* What the access mark's tooltip SAYS for a given access state.
|
|
475
|
+
*
|
|
476
|
+
* 🔴 The defaults speak collections' vocabulary — "hidden from a locked page" — because
|
|
477
|
+
* that is the app the mark was built for. The mark itself is generic and the words are
|
|
478
|
+
* not: the vault's protected folders wear the same padlock meaning "everything in here
|
|
479
|
+
* needs the master password", and a claim of hiddenness would be false there (the rows
|
|
480
|
+
* are visible; their SECRETS are sealed). So the words are an argument with a default,
|
|
481
|
+
* resolved here rather than inline, where they can be tested without simulating a hover.
|
|
482
|
+
*
|
|
483
|
+
* Empty string for `normal` — a row with no mark has nothing to say.
|
|
484
|
+
*/
|
|
485
|
+
export function accessMarkCopy(
|
|
486
|
+
access: FileTreeAccess | undefined,
|
|
487
|
+
copy?: AccessMarkCopy,
|
|
488
|
+
): string {
|
|
489
|
+
if (access === 'locked') return copy?.locked ?? 'Hidden — unlock to see what is in here';
|
|
490
|
+
if (access === 'private') {
|
|
491
|
+
// Deliberately "this or what is in it": a consumer sets `private` both for a row that
|
|
492
|
+
// is itself private and for a public folder holding private children (collections does
|
|
493
|
+
// exactly that), and a wording that claimed only the first would be false on half the
|
|
494
|
+
// rows it marks.
|
|
495
|
+
return copy?.private ?? 'Private — this, or something in it, is hidden from a locked page';
|
|
496
|
+
}
|
|
497
|
+
return '';
|
|
498
|
+
}
|
|
499
|
+
|
|
467
500
|
/**
|
|
468
501
|
* The next row whose name starts with `query`, searching from `fromIndex` and wrapping.
|
|
469
502
|
*
|
|
@@ -152,8 +152,10 @@ const sourceOf = (subpath: string): string => {
|
|
|
152
152
|
* Resolution here is therefore explicit rather than inherited. The redirected root is
|
|
153
153
|
* used when it lies outside the repo (the normal case — the runner always sets
|
|
154
154
|
* `$FORGE_STATE`, and the preload's sweep then cleans up after a killed run). When it
|
|
155
|
-
* does not, this hops to `$
|
|
156
|
-
*
|
|
155
|
+
* does not, this hops to `$FORGE_STATE/test-scratch.noindex` — or, with no generation
|
|
156
|
+
* in the environment, `~/.code/test-scratch.noindex`, which is where this machine keeps
|
|
157
|
+
* everything it builds for itself. `resolveTestTmpRoot` refuses `$HOME` for the general
|
|
158
|
+
* default and this check genuinely requires an isolated root: that isolation is the whole
|
|
157
159
|
* measurement. Stale siblings are swept by mtime, the same way the preload does it,
|
|
158
160
|
* so the hop cannot accumulate.
|
|
159
161
|
*/
|
|
@@ -171,7 +173,17 @@ const fixtureRoot = (): string => {
|
|
|
171
173
|
'a check that cannot isolate itself is not a passing check',
|
|
172
174
|
);
|
|
173
175
|
}
|
|
174
|
-
|
|
176
|
+
// 🔴 Under `$FORGE_STATE`, or under `~/.code` — never a dotted directory of its own in
|
|
177
|
+
// `$HOME`. Measured 2026-09-17: this hop had left three `~/.cursedbelt*-leaf-fixture.noindex`
|
|
178
|
+
// trees in the owner's home directory, indistinguishable at a glance from the forty a Mac's
|
|
179
|
+
// real software puts there, and the sweep below only ever cleaned their INSIDES. A check that
|
|
180
|
+
// tidies up after itself and still leaves its address behind for ever is the shape this
|
|
181
|
+
// generation's home-dir rule exists to stop; `tools/check-home-dir.ts` now fails on it.
|
|
182
|
+
const state = process.env.FORGE_STATE?.trim();
|
|
183
|
+
const outside =
|
|
184
|
+
state !== undefined && state !== '' && !`${state}/`.startsWith(REPO_PREFIX)
|
|
185
|
+
? `${state}/test-scratch.noindex/${pkg.name}-leaf-fixture`
|
|
186
|
+
: `${home}/.code/test-scratch.noindex/${pkg.name}-leaf-fixture`;
|
|
175
187
|
try {
|
|
176
188
|
const cutoff = Date.now() - 6 * 60 * 60 * 1000;
|
|
177
189
|
for (const entry of readdirSync(outside)) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🔴 `bun publish` packs the WORKING DIRECTORY, not the git index — so a package can
|
|
3
|
+
* ship a file the repo does not have.
|
|
4
|
+
*
|
|
5
|
+
* ── What this cost, 2026-09-15 ─────────────────────────────────────────────────────
|
|
6
|
+
* The cursedbelt split (task 148) moved two integration specs into this package from
|
|
7
|
+
* `cursedbelt`. `git add -u .` stages only files git already knows about, so both were
|
|
8
|
+
* committed nowhere — and `bun run verify` was green *because they were present on disk*,
|
|
9
|
+
* which is the same reason nobody noticed. `1.0.2` went to the registry carrying two
|
|
10
|
+
* `src/` files that existed in no commit, and `git status` said `tracked-dirty=0`.
|
|
11
|
+
*
|
|
12
|
+
* "The tree is clean" and "everything that ships is committed" are different claims, and
|
|
13
|
+
* only the first one is what people actually check. npm versions are immutable, so the
|
|
14
|
+
* window to notice closes at publish time.
|
|
15
|
+
*
|
|
16
|
+
* ── Why this shape ─────────────────────────────────────────────────────────────────
|
|
17
|
+
* It asks git, for exactly the directories `package.json#files` promises to ship, which
|
|
18
|
+
* of those paths git does not track. That is narrower than "any untracked file" on
|
|
19
|
+
* purpose: scratch files, `.agent.noindex/`, a half-written script and an uncommitted
|
|
20
|
+
* lockfile are all legitimate states for a working tree and reddening on them would teach
|
|
21
|
+
* people to skip this. A file INSIDE the published surface is the only case where
|
|
22
|
+
* untracked means "about to ship something no one can review or revert".
|
|
23
|
+
*
|
|
24
|
+
* 🔴 It must run from a real checkout to mean anything, so it refuses rather than passes
|
|
25
|
+
* when `git` cannot answer — a check that silently degrades to green is worse than none.
|
|
26
|
+
*/
|
|
27
|
+
import { describe, expect, test } from 'bun:test';
|
|
28
|
+
import { spawnSync } from 'node:child_process';
|
|
29
|
+
import { existsSync } from 'node:fs';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import pkg from '../package.json';
|
|
32
|
+
|
|
33
|
+
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
34
|
+
|
|
35
|
+
/** The directories `files` promises to ship, minus build output nobody commits. */
|
|
36
|
+
const shippedDirs = (): string[] =>
|
|
37
|
+
((pkg as { files?: string[] }).files ?? []).filter((f) => f !== 'dist' && existsSync(`${repoRoot}/${f}`));
|
|
38
|
+
|
|
39
|
+
const git = (...args: string[]): { ok: boolean; out: string } => {
|
|
40
|
+
const r = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8' });
|
|
41
|
+
return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe('every file inside the published surface is tracked', () => {
|
|
45
|
+
test('git can answer at all — a degraded check must refuse, not pass', () => {
|
|
46
|
+
expect(git('rev-parse', '--is-inside-work-tree').ok, 'not a git checkout, so this check proves nothing').toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('package.json names a shippable directory to scan', () => {
|
|
50
|
+
// Vacuity guard: an empty `files`, or one naming only `dist`, makes the assertion
|
|
51
|
+
// below pass while scanning nothing at all.
|
|
52
|
+
expect(shippedDirs().length, '`files` names no committed directory — nothing would be scanned').toBeGreaterThan(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('no untracked file sits inside a directory `files` ships', () => {
|
|
56
|
+
// --others = untracked; --exclude-standard honours .gitignore, so deliberately
|
|
57
|
+
// ignored paths (.agent.noindex, node_modules) are not offences.
|
|
58
|
+
const { ok, out } = git('ls-files', '--others', '--exclude-standard', '--', ...shippedDirs());
|
|
59
|
+
expect(ok, 'git ls-files failed').toBe(true);
|
|
60
|
+
const untracked = out === '' ? [] : out.split('\n');
|
|
61
|
+
expect(
|
|
62
|
+
untracked,
|
|
63
|
+
'these would be PUBLISHED but exist in no commit — `bun publish` packs the working directory, not the index,\n' +
|
|
64
|
+
'so the registry would carry code nobody can review, diff or revert, and npm versions are immutable.\n' +
|
|
65
|
+
'Commit them (or add them to .gitignore if they genuinely must not ship):\n ' +
|
|
66
|
+
untracked.join('\n '),
|
|
67
|
+
).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|