skillrepo 4.9.2 → 4.11.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/README.md +30 -3
- package/package.json +1 -1
- package/src/commands/init.mjs +18 -4
- package/src/lib/artifact-registry.mjs +28 -10
- package/src/lib/constants.mjs +11 -0
- package/src/lib/errors.mjs +23 -0
- package/src/lib/file-write.mjs +47 -47
- package/src/lib/foreign-content.mjs +461 -0
- package/src/lib/gitignore-section.mjs +223 -0
- package/src/lib/mergers/gitignore.mjs +22 -65
- package/src/lib/paths.mjs +11 -0
- package/src/lib/removers/gitignore.mjs +38 -3
- package/src/lib/skillset-declaration.mjs +477 -0
- package/src/lib/sync.mjs +179 -0
- package/src/test/commands/add.test.mjs +24 -0
- package/src/test/commands/get.test.mjs +24 -0
- package/src/test/commands/init.test.mjs +37 -0
- package/src/test/commands/update.test.mjs +194 -0
- package/src/test/e2e/cli-commands.test.mjs +63 -0
- package/src/test/lib/errors.test.mjs +28 -0
- package/src/test/lib/file-write.test.mjs +1 -1
- package/src/test/lib/foreign-content.test.mjs +522 -0
- package/src/test/lib/gitignore-section.test.mjs +213 -0
- package/src/test/lib/skillset-declaration.test.mjs +514 -0
- package/src/test/lib/sync.test.mjs +635 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +65 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/skillset-declaration.mjs (#2362).
|
|
3
|
+
*
|
|
4
|
+
* The repo skillset-declaration resolver: walk-up discovery (cwd →
|
|
5
|
+
* `.git` boundary or fs root), the v1 single-file rule (nesting fails
|
|
6
|
+
* closed), imperative sub-schema validation per the D6 note, the D3
|
|
7
|
+
* `@` reservation, and the pre-H8 dark-shipping error. Every
|
|
8
|
+
* fail-closed outcome carries EXIT_UNRESOLVABLE (6).
|
|
9
|
+
*
|
|
10
|
+
* Sandboxes create their own `.git` boundary so walks never escape
|
|
11
|
+
* into the real filesystem. `realpathSync` on the sandbox keeps path
|
|
12
|
+
* assertions stable on macOS (`/tmp` → `/private/tmp`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import {
|
|
18
|
+
mkdtempSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
chmodSync,
|
|
23
|
+
realpathSync,
|
|
24
|
+
symlinkSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { tmpdir, platform } from "node:os";
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
findDeclarationFiles,
|
|
31
|
+
resolveDeclaration,
|
|
32
|
+
declarationUnavailableError,
|
|
33
|
+
DECLARATION_KEY,
|
|
34
|
+
DECLARATION_SCHEMA_VERSION,
|
|
35
|
+
} from "../../lib/skillset-declaration.mjs";
|
|
36
|
+
import { SKILLSET_DECLARATION_FILE } from "../../lib/constants.mjs";
|
|
37
|
+
import { CliError, EXIT_UNRESOLVABLE } from "../../lib/errors.mjs";
|
|
38
|
+
|
|
39
|
+
let sandbox;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
sandbox = realpathSync(mkdtempSync(join(tmpdir(), "cli-skillset-decl-")));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** Create a directory tree under the sandbox and return its abs path. */
|
|
50
|
+
function dir(...segments) {
|
|
51
|
+
const p = join(sandbox, ...segments);
|
|
52
|
+
mkdirSync(p, { recursive: true });
|
|
53
|
+
return p;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Write a declaration file (raw string or object) into `where`. */
|
|
57
|
+
function writeDecl(where, content) {
|
|
58
|
+
const body = typeof content === "string" ? content : JSON.stringify(content, null, 2);
|
|
59
|
+
writeFileSync(join(where, SKILLSET_DECLARATION_FILE), body);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const VALID_BLOCK = {
|
|
63
|
+
version: 1,
|
|
64
|
+
name: "checkout-service",
|
|
65
|
+
use: "acme/backend-core",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** A well-formed declaration file body. */
|
|
69
|
+
function validDecl(overrides = {}) {
|
|
70
|
+
return { [DECLARATION_KEY]: { ...VALID_BLOCK, ...overrides } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Assert an "invalid" resolution with the typed exit code. */
|
|
74
|
+
function assertInvalid(resolution, messagePattern) {
|
|
75
|
+
assert.equal(resolution.status, "invalid");
|
|
76
|
+
assert.ok(resolution.error instanceof CliError, "error must be a CliError");
|
|
77
|
+
assert.equal(resolution.error.exitCode, EXIT_UNRESOLVABLE);
|
|
78
|
+
if (messagePattern) assert.match(resolution.error.message, messagePattern);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── findDeclarationFiles — walk-up discovery ───────────────────────────
|
|
82
|
+
|
|
83
|
+
describe("findDeclarationFiles — walk-up discovery", () => {
|
|
84
|
+
it("returns [] when no file exists between cwd and the repo boundary", () => {
|
|
85
|
+
const root = dir("repo");
|
|
86
|
+
mkdirSync(join(root, ".git"));
|
|
87
|
+
const cwd = dir("repo", "src", "deep");
|
|
88
|
+
assert.deepEqual(findDeclarationFiles({ cwd }), []);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("finds the file at cwd itself (repo root case)", () => {
|
|
92
|
+
const root = dir("repo");
|
|
93
|
+
mkdirSync(join(root, ".git"));
|
|
94
|
+
writeDecl(root, validDecl());
|
|
95
|
+
assert.deepEqual(findDeclarationFiles({ cwd: root }), [
|
|
96
|
+
join(root, SKILLSET_DECLARATION_FILE),
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("finds the repo-root file from a nested subdirectory", () => {
|
|
101
|
+
const root = dir("repo");
|
|
102
|
+
mkdirSync(join(root, ".git"));
|
|
103
|
+
writeDecl(root, validDecl());
|
|
104
|
+
const cwd = dir("repo", "packages", "app", "src");
|
|
105
|
+
assert.deepEqual(findDeclarationFiles({ cwd }), [
|
|
106
|
+
join(root, SKILLSET_DECLARATION_FILE),
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("stops at the .git boundary — a file ABOVE the repo is never consulted", () => {
|
|
111
|
+
writeDecl(sandbox, validDecl()); // outside the repo
|
|
112
|
+
const root = dir("outer", "repo");
|
|
113
|
+
mkdirSync(join(root, ".git"));
|
|
114
|
+
const cwd = dir("outer", "repo", "src");
|
|
115
|
+
assert.deepEqual(findDeclarationFiles({ cwd }), []);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("treats a .git FILE (worktree/submodule pointer) as the boundary", () => {
|
|
119
|
+
writeDecl(sandbox, validDecl()); // outside — must not be reached
|
|
120
|
+
const root = dir("wt");
|
|
121
|
+
writeFileSync(join(root, ".git"), "gitdir: /elsewhere/.git/worktrees/wt\n");
|
|
122
|
+
writeDecl(root, validDecl());
|
|
123
|
+
const cwd = dir("wt", "lib");
|
|
124
|
+
assert.deepEqual(findDeclarationFiles({ cwd }), [
|
|
125
|
+
join(root, SKILLSET_DECLARATION_FILE),
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("outside any git repo, ancestors up the tree are found (ancestor-config semantics)", () => {
|
|
130
|
+
// No .git anywhere in the sandbox: the walk ascends past the
|
|
131
|
+
// sandbox toward the fs root. The sandbox-level file must be
|
|
132
|
+
// found; we only assert on entries inside the sandbox so a
|
|
133
|
+
// hypothetical stray file on the host cannot flake the test.
|
|
134
|
+
writeDecl(sandbox, validDecl());
|
|
135
|
+
const cwd = dir("plain", "nested");
|
|
136
|
+
const found = findDeclarationFiles({ cwd }).filter((p) => p.startsWith(sandbox));
|
|
137
|
+
assert.deepEqual(found, [join(sandbox, SKILLSET_DECLARATION_FILE)]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("returns nearest-first when multiple files exist on the path", () => {
|
|
141
|
+
const root = dir("repo");
|
|
142
|
+
mkdirSync(join(root, ".git"));
|
|
143
|
+
writeDecl(root, validDecl());
|
|
144
|
+
const sub = dir("repo", "sub");
|
|
145
|
+
writeDecl(sub, validDecl());
|
|
146
|
+
assert.deepEqual(findDeclarationFiles({ cwd: sub }), [
|
|
147
|
+
join(sub, SKILLSET_DECLARATION_FILE),
|
|
148
|
+
join(root, SKILLSET_DECLARATION_FILE),
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("skips a DIRECTORY named skillrepo.json", () => {
|
|
153
|
+
const root = dir("repo");
|
|
154
|
+
mkdirSync(join(root, ".git"));
|
|
155
|
+
mkdirSync(join(root, SKILLSET_DECLARATION_FILE));
|
|
156
|
+
assert.deepEqual(findDeclarationFiles({ cwd: root }), []);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── resolveDeclaration — statuses ──────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
describe("resolveDeclaration — absent / config-only / declared", () => {
|
|
163
|
+
it("absent when no file exists", () => {
|
|
164
|
+
const root = dir("repo");
|
|
165
|
+
mkdirSync(join(root, ".git"));
|
|
166
|
+
assert.deepEqual(resolveDeclaration({ cwd: root }), { status: "absent" });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("config-only when the file has no skillset key — carries rootDir for the probe anchor", () => {
|
|
170
|
+
const root = dir("repo");
|
|
171
|
+
mkdirSync(join(root, ".git"));
|
|
172
|
+
writeDecl(root, { someFutureFeature: true });
|
|
173
|
+
const res = resolveDeclaration({ cwd: dir("repo", "src") });
|
|
174
|
+
assert.equal(res.status, "config-only");
|
|
175
|
+
assert.equal(res.rootDir, root);
|
|
176
|
+
assert.equal(res.filePath, join(root, SKILLSET_DECLARATION_FILE));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("declared for a well-formed declaration; extra defaults to []", () => {
|
|
180
|
+
const root = dir("repo");
|
|
181
|
+
mkdirSync(join(root, ".git"));
|
|
182
|
+
writeDecl(root, validDecl());
|
|
183
|
+
const res = resolveDeclaration({ cwd: root });
|
|
184
|
+
assert.equal(res.status, "declared");
|
|
185
|
+
assert.equal(res.rootDir, root);
|
|
186
|
+
assert.deepEqual(res.declaration, {
|
|
187
|
+
version: 1,
|
|
188
|
+
name: "checkout-service",
|
|
189
|
+
use: "acme/backend-core",
|
|
190
|
+
extra: [],
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("declared with extras — the array is copied, not aliased", () => {
|
|
195
|
+
const root = dir("repo");
|
|
196
|
+
mkdirSync(join(root, ".git"));
|
|
197
|
+
writeDecl(root, validDecl({ extra: ["acme/db-migrations", "acme/testing"] }));
|
|
198
|
+
const res = resolveDeclaration({ cwd: root });
|
|
199
|
+
assert.equal(res.status, "declared");
|
|
200
|
+
assert.deepEqual(res.declaration.extra, ["acme/db-migrations", "acme/testing"]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("declared from a subdirectory — rootDir is the detected root, not cwd", () => {
|
|
204
|
+
const root = dir("repo");
|
|
205
|
+
mkdirSync(join(root, ".git"));
|
|
206
|
+
writeDecl(root, validDecl());
|
|
207
|
+
const cwd = dir("repo", "a", "b");
|
|
208
|
+
const res = resolveDeclaration({ cwd });
|
|
209
|
+
assert.equal(res.status, "declared");
|
|
210
|
+
assert.equal(res.rootDir, root);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("empty extra array is valid and stays empty", () => {
|
|
214
|
+
const root = dir("repo");
|
|
215
|
+
mkdirSync(join(root, ".git"));
|
|
216
|
+
writeDecl(root, validDecl({ extra: [] }));
|
|
217
|
+
const res = resolveDeclaration({ cwd: root });
|
|
218
|
+
assert.equal(res.status, "declared");
|
|
219
|
+
assert.deepEqual(res.declaration.extra, []);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("tolerates a UTF-8 BOM (Windows editors)", () => {
|
|
223
|
+
const root = dir("repo");
|
|
224
|
+
mkdirSync(join(root, ".git"));
|
|
225
|
+
writeFileSync(
|
|
226
|
+
join(root, SKILLSET_DECLARATION_FILE),
|
|
227
|
+
"" + JSON.stringify(validDecl()),
|
|
228
|
+
);
|
|
229
|
+
assert.equal(resolveDeclaration({ cwd: root }).status, "declared");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("tolerates unknown TOP-LEVEL keys (future skillrepo.json features)", () => {
|
|
233
|
+
const root = dir("repo");
|
|
234
|
+
mkdirSync(join(root, ".git"));
|
|
235
|
+
writeDecl(root, { ...validDecl(), telemetry: { enabled: false } });
|
|
236
|
+
assert.equal(resolveDeclaration({ cwd: root }).status, "declared");
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ── resolveDeclaration — fail-closed outcomes ──────────────────────────
|
|
241
|
+
|
|
242
|
+
describe("resolveDeclaration — nesting fails closed", () => {
|
|
243
|
+
it("two files on the walk path → invalid, names both, says not yet supported", () => {
|
|
244
|
+
const root = dir("repo");
|
|
245
|
+
mkdirSync(join(root, ".git"));
|
|
246
|
+
writeDecl(root, validDecl());
|
|
247
|
+
const sub = dir("repo", "service");
|
|
248
|
+
writeDecl(sub, validDecl({ name: "service" }));
|
|
249
|
+
const res = resolveDeclaration({ cwd: sub });
|
|
250
|
+
assertInvalid(res, /not yet supported/);
|
|
251
|
+
assert.match(res.error.message, /2 skillrepo\.json files/);
|
|
252
|
+
assert.match(res.error.hint, /repository root/);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("a config-only file nested below a declared root still fails closed (presence, not content)", () => {
|
|
256
|
+
const root = dir("repo");
|
|
257
|
+
mkdirSync(join(root, ".git"));
|
|
258
|
+
writeDecl(root, validDecl());
|
|
259
|
+
const sub = dir("repo", "tool");
|
|
260
|
+
writeDecl(sub, { unrelated: true });
|
|
261
|
+
assertInvalid(resolveDeclaration({ cwd: sub }), /not yet supported/);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("resolveDeclaration — symlinked declaration files", () => {
|
|
266
|
+
/** Try to create a symlink; returns false where unsupported (win32 without privilege). */
|
|
267
|
+
function trySymlink(target, linkPath) {
|
|
268
|
+
try {
|
|
269
|
+
symlinkSync(target, linkPath);
|
|
270
|
+
return true;
|
|
271
|
+
} catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
it("DANGLING symlink fails closed, never reads as absent (fail-open regression guard)", (t) => {
|
|
277
|
+
const root = dir("repo");
|
|
278
|
+
mkdirSync(join(root, ".git"));
|
|
279
|
+
if (!trySymlink("./does-not-exist.json", join(root, SKILLSET_DECLARATION_FILE))) {
|
|
280
|
+
t.skip("symlinks unsupported on this platform/privilege level");
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const res = resolveDeclaration({ cwd: root });
|
|
284
|
+
// existsSync FOLLOWS symlinks and reports a dangling one as false,
|
|
285
|
+
// which classified the repo as undeclared and delivered the whole
|
|
286
|
+
// library (2026-08-07 prod-readiness QA). lstat-based discovery
|
|
287
|
+
// must route this into the fail-closed cannot-read branch.
|
|
288
|
+
assertInvalid(res, /cannot read/);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("WORKING symlink to a valid declaration file resolves as declared", (t) => {
|
|
292
|
+
const root = dir("repo");
|
|
293
|
+
mkdirSync(join(root, ".git"));
|
|
294
|
+
writeFileSync(join(root, "governance.json"), JSON.stringify(validDecl()));
|
|
295
|
+
if (!trySymlink("./governance.json", join(root, SKILLSET_DECLARATION_FILE))) {
|
|
296
|
+
t.skip("symlinks unsupported on this platform/privilege level");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const res = resolveDeclaration({ cwd: root });
|
|
300
|
+
assert.equal(res.status, "declared");
|
|
301
|
+
assert.equal(res.declaration.use, "acme/backend-core");
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("resolveDeclaration — unreadable and unparseable files", () => {
|
|
306
|
+
it("invalid JSON → EXIT_UNRESOLVABLE with fix-or-remove guidance", () => {
|
|
307
|
+
const root = dir("repo");
|
|
308
|
+
mkdirSync(join(root, ".git"));
|
|
309
|
+
writeFileSync(join(root, SKILLSET_DECLARATION_FILE), "{ not json");
|
|
310
|
+
const res = resolveDeclaration({ cwd: root });
|
|
311
|
+
assertInvalid(res, /not valid JSON/);
|
|
312
|
+
assert.match(res.error.hint, /remove the file/);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("top-level JSON array → invalid", () => {
|
|
316
|
+
const root = dir("repo");
|
|
317
|
+
mkdirSync(join(root, ".git"));
|
|
318
|
+
writeFileSync(join(root, SKILLSET_DECLARATION_FILE), "[1, 2]");
|
|
319
|
+
assertInvalid(resolveDeclaration({ cwd: root }), /JSON object at the top level/);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("top-level null → invalid", () => {
|
|
323
|
+
const root = dir("repo");
|
|
324
|
+
mkdirSync(join(root, ".git"));
|
|
325
|
+
writeFileSync(join(root, SKILLSET_DECLARATION_FILE), "null");
|
|
326
|
+
assertInvalid(resolveDeclaration({ cwd: root }), /found null/);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("unreadable file → invalid (cannot rule out a declaration)", (t) => {
|
|
330
|
+
if (platform() === "win32" || process.getuid?.() === 0) {
|
|
331
|
+
t.skip("chmod-based unreadability is not enforceable here");
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const root = dir("repo");
|
|
335
|
+
mkdirSync(join(root, ".git"));
|
|
336
|
+
writeDecl(root, validDecl());
|
|
337
|
+
chmodSync(join(root, SKILLSET_DECLARATION_FILE), 0o000);
|
|
338
|
+
try {
|
|
339
|
+
assertInvalid(resolveDeclaration({ cwd: root }), /cannot read/);
|
|
340
|
+
} finally {
|
|
341
|
+
chmodSync(join(root, SKILLSET_DECLARATION_FILE), 0o644);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe("resolveDeclaration — sub-schema validation", () => {
|
|
347
|
+
/** Resolve a declaration whose block is `block`, from a fresh repo. */
|
|
348
|
+
function resolveBlock(block) {
|
|
349
|
+
const root = dir(`repo-${Math.random().toString(36).slice(2, 8)}`);
|
|
350
|
+
mkdirSync(join(root, ".git"));
|
|
351
|
+
writeDecl(root, { [DECLARATION_KEY]: block });
|
|
352
|
+
return resolveDeclaration({ cwd: root });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
it("skillset value must be an object — string rejected", () => {
|
|
356
|
+
assertInvalid(resolveBlock("acme/backend-core"), /must be an object/);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("skillset value must be an object — array rejected", () => {
|
|
360
|
+
assertInvalid(resolveBlock(["acme/backend-core"]), /must be an object/);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("skillset value must be an object — null rejected", () => {
|
|
364
|
+
assertInvalid(resolveBlock(null), /must be an object/);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("unknown field inside the block is a typo guard, names the field", () => {
|
|
368
|
+
const res = resolveBlock({ ...VALID_BLOCK, extras: ["acme/x"] });
|
|
369
|
+
assertInvalid(res, /unknown field "extras"/);
|
|
370
|
+
assert.match(res.error.hint, /typos/);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("multiple unknown fields are all named", () => {
|
|
374
|
+
const res = resolveBlock({ ...VALID_BLOCK, extras: [], skillset: "x" });
|
|
375
|
+
assertInvalid(res, /unknown fields "extras", "skillset"/);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("missing version → invalid with found-nothing copy", () => {
|
|
379
|
+
const { version, ...rest } = VALID_BLOCK;
|
|
380
|
+
assertInvalid(resolveBlock(rest), /integer "version" field \(found nothing\)/);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("string version → invalid", () => {
|
|
384
|
+
assertInvalid(resolveBlock({ ...VALID_BLOCK, version: "1" }), /found a string/);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("future schema version → not-supported copy pointing at a CLI update", () => {
|
|
388
|
+
const res = resolveBlock({ ...VALID_BLOCK, version: 2 });
|
|
389
|
+
assertInvalid(res, /schema version 2 is not supported/);
|
|
390
|
+
assert.match(res.error.hint, /latest skillrepo CLI/);
|
|
391
|
+
assert.equal(DECLARATION_SCHEMA_VERSION, 1);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("missing name → invalid", () => {
|
|
395
|
+
const { name, ...rest } = VALID_BLOCK;
|
|
396
|
+
assertInvalid(resolveBlock(rest), /"name" field/);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("whitespace-only name → invalid", () => {
|
|
400
|
+
assertInvalid(resolveBlock({ ...VALID_BLOCK, name: " " }), /non-empty string "name"/);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("non-string name → invalid", () => {
|
|
404
|
+
assertInvalid(resolveBlock({ ...VALID_BLOCK, name: 7 }), /found a number/);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("missing use → invalid", () => {
|
|
408
|
+
const { use, ...rest } = VALID_BLOCK;
|
|
409
|
+
assertInvalid(resolveBlock(rest), /"use" field must be a non-empty "owner\/name" string/);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
for (const bad of ["acme", "acme/", "/core", "a/b/c", " "]) {
|
|
413
|
+
it(`use ${JSON.stringify(bad)} fails the owner/name shape`, () => {
|
|
414
|
+
assertInvalid(resolveBlock({ ...VALID_BLOCK, use: bad }), /"use"/);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
it("use with @ version syntax hits the D3 reservation, not the shape error", () => {
|
|
419
|
+
const res = resolveBlock({ ...VALID_BLOCK, use: "acme/backend-core@2" });
|
|
420
|
+
assertInvalid(res, /"@" version syntax, which is not yet supported/);
|
|
421
|
+
assert.match(res.error.message, /library pins/);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("extra must be an array — object rejected", () => {
|
|
425
|
+
assertInvalid(resolveBlock({ ...VALID_BLOCK, extra: {} }), /"extra" field must be an array/);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("non-string extra entry → invalid, names the index", () => {
|
|
429
|
+
assertInvalid(resolveBlock({ ...VALID_BLOCK, extra: [42] }), /"extra\[0\]"/);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("bad-shape extra entry → invalid at its index", () => {
|
|
433
|
+
const res = resolveBlock({ ...VALID_BLOCK, extra: ["acme/ok", "just-a-name"] });
|
|
434
|
+
assertInvalid(res, /"extra\[1\]"/);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("extra entry with @ hits the D3 reservation", () => {
|
|
438
|
+
assertInvalid(
|
|
439
|
+
resolveBlock({ ...VALID_BLOCK, extra: ["acme/db@1.2"] }),
|
|
440
|
+
/not yet supported/,
|
|
441
|
+
);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("control characters in echoed content are escaped, never raw", () => {
|
|
445
|
+
const res = resolveBlock({ ...VALID_BLOCK, use: "acme/bad\u0007name" });
|
|
446
|
+
assert.equal(res.status, "invalid");
|
|
447
|
+
assert.ok(!res.error.message.includes(String.fromCharCode(7)), "raw control char must not pass through");
|
|
448
|
+
assert.match(res.error.message, /\\u0007/);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("C1 control characters are rejected too (canonical file-write class, not a narrower copy)", () => {
|
|
452
|
+
// is CSI in 8-bit terminals — the C1 range a hand-rolled
|
|
453
|
+
// C0-only regex would miss (code-review round-1 finding).
|
|
454
|
+
const res = resolveBlock({ ...VALID_BLOCK, name: "svc\u009bname" });
|
|
455
|
+
assertInvalid(res, /control characters/);
|
|
456
|
+
assert.match(res.error.message, /\\u009b/);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("control characters in unknown field names are escaped", () => {
|
|
460
|
+
const res = resolveBlock({ ...VALID_BLOCK, ["bad\u001bkey"]: 1 });
|
|
461
|
+
assert.equal(res.status, "invalid");
|
|
462
|
+
assert.ok(!res.error.message.includes(String.fromCharCode(27)));
|
|
463
|
+
assert.match(res.error.message, /\\u001b/);
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
describe("resolveDeclaration — error copy paths", () => {
|
|
468
|
+
it("names the bare filename when the file is at cwd", () => {
|
|
469
|
+
const root = dir("repo");
|
|
470
|
+
mkdirSync(join(root, ".git"));
|
|
471
|
+
writeFileSync(join(root, SKILLSET_DECLARATION_FILE), "{bad");
|
|
472
|
+
const res = resolveDeclaration({ cwd: root });
|
|
473
|
+
assert.match(res.error.message, /^skillrepo\.json is not valid JSON/);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("names the absolute path when the file is above cwd", () => {
|
|
477
|
+
const root = dir("repo");
|
|
478
|
+
mkdirSync(join(root, ".git"));
|
|
479
|
+
writeFileSync(join(root, SKILLSET_DECLARATION_FILE), "{bad");
|
|
480
|
+
const cwd = dir("repo", "sub");
|
|
481
|
+
const res = resolveDeclaration({ cwd });
|
|
482
|
+
assert.ok(
|
|
483
|
+
res.error.message.startsWith(join(root, SKILLSET_DECLARATION_FILE)),
|
|
484
|
+
`expected absolute path prefix, got: ${res.error.message}`,
|
|
485
|
+
);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// ── declarationUnavailableError — the dark-shipping copy ───────────────
|
|
490
|
+
|
|
491
|
+
describe("declarationUnavailableError", () => {
|
|
492
|
+
function declaredResolution() {
|
|
493
|
+
const root = dir("repo");
|
|
494
|
+
mkdirSync(join(root, ".git"));
|
|
495
|
+
writeDecl(root, validDecl());
|
|
496
|
+
return resolveDeclaration({ cwd: root });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
it("carries EXIT_UNRESOLVABLE and the not-yet-available copy", () => {
|
|
500
|
+
const err = declarationUnavailableError(declaredResolution());
|
|
501
|
+
assert.ok(err instanceof CliError);
|
|
502
|
+
assert.equal(err.exitCode, EXIT_UNRESOLVABLE);
|
|
503
|
+
assert.match(err.message, /skillset "acme\/backend-core"/);
|
|
504
|
+
assert.match(err.message, /not yet available/);
|
|
505
|
+
assert.match(err.message, /never the whole library/);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("never implies the user's file is wrong; hint offers keep-or-remove", () => {
|
|
509
|
+
const err = declarationUnavailableError(declaredResolution());
|
|
510
|
+
assert.ok(!/invalid|malformed|wrong|fix/i.test(err.message));
|
|
511
|
+
assert.match(err.hint, /Keep the declaration/);
|
|
512
|
+
assert.match(err.hint, /remove/);
|
|
513
|
+
});
|
|
514
|
+
});
|