@bongos/core 1.20.40 → 1.20.42
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/.bongos-core.json +265 -235
- package/.claude/skills/design/SKILL.md +6 -5
- package/docs/adr/0081-tool-agnostic-design-layer.md +1 -1
- package/docs/adr/0198-third-party-skill-vendoring-policy.md +2 -2
- package/docs/architecture.md +1 -1
- package/docs/file-map.md +51 -54
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +7 -5
- package/docs/onboarding/slash-commands.md +1 -1
- package/modules/design-styles/CLAUDE.md +15 -0
- package/modules/design-styles/module.json +12 -0
- package/modules/{ui-design → design-styles}/skills/PREAMBLE.md +2 -2
- package/modules/{ui-design → design-styles}/skills/README.md +6 -6
- package/modules/{ui-design → design-styles}/skills/brandkit/SKILL.md +2 -2
- package/modules/{ui-design → design-styles}/skills/design-taste-frontend/SKILL.md +2 -2
- package/modules/{ui-design → design-styles}/skills/gpt-taste/SKILL.md +2 -2
- package/modules/{ui-design → design-styles}/skills/high-end-visual-design/SKILL.md +2 -2
- package/modules/{ui-design → design-styles}/skills/image-to-code/SKILL.md +2 -2
- package/modules/{ui-design → design-styles}/skills/imagegen-frontend-mobile/SKILL.md +2 -2
- package/modules/{ui-design → design-styles}/skills/imagegen-frontend-web/SKILL.md +2 -2
- package/modules/{ui-design → design-styles}/skills/impeccable/SKILL.md +3 -3
- package/modules/{ui-design → design-styles}/skills/industrial-brutalist-ui/SKILL.md +2 -2
- package/modules/{ui-design → design-styles}/skills/minimalist-ui/SKILL.md +2 -2
- package/modules/{ui-design → design-styles}/skills/policy.json +1 -1
- package/modules/{ui-design → design-styles}/skills/redesign-existing-projects/SKILL.md +2 -2
- package/modules/{ui-design → design-styles}/skills/stitch-design-taste/SKILL.md +2 -2
- package/modules/{ui-design → design-styles}/skills/style/SKILL.md +2 -2
- package/modules/pixel-art/CLAUDE.md +15 -0
- package/modules/pixel-art/module.json +12 -0
- package/modules/provisioning/starter-bundles.js +15 -0
- package/modules/ui-design/kit/serve.js +2 -0
- package/modules/ui-design/module.json +3 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/doc-cli-guard.js +1 -1
- package/scripts/gds/fitness-ratchets.js +4 -1
- package/scripts/gds/fitness.js +2 -1
- package/scripts/gds/module-artifact.js +1 -1
- package/scripts/gds/module-assess-security.js +251 -0
- package/scripts/gds/publish-manifest.js +2 -0
- package/scripts/gds/skill-lint.js +30 -8
- package/src/bongos/module-scope-map.js +8 -2
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +9 -8
- package/tests/module_assess_security.mjs +216 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/opt_in_skill_modules.mjs +165 -0
- package/tests/skill_lint.mjs +26 -2
- package/tests/ui_design_skills.mjs +29 -17
- package/modules/ui-design/skills/design-taste-frontend-v1/SKILL.md +0 -135
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/adapt.md +0 -0
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/animate.md +0 -0
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/audit.md +0 -0
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/bolder.md +0 -0
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/clarify.md +0 -0
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/colorize.md +0 -0
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/critique.md +0 -0
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/distill.md +0 -0
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/document.md +0 -0
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/extract.md +0 -0
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/harden.md +0 -0
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/layout.md +0 -0
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/onboard.md +0 -0
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/optimize.md +0 -0
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/polish.md +0 -0
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/quieter.md +0 -0
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/shape.md +0 -0
- /package/modules/{ui-design → design-styles}/skills/impeccable/reference/typeset.md +0 -0
- /package/{.claude → modules/pixel-art}/skills/otb-character-review/SKILL.md +0 -0
- /package/{.claude → modules/pixel-art}/skills/otb-design-review/SKILL.md +0 -0
- /package/{.claude → modules/pixel-art}/skills/otb-feedback-capture/SKILL.md +0 -0
- /package/{.claude → modules/pixel-art}/skills/otb-figma-sync/SKILL.md +0 -0
- /package/{.claude → modules/pixel-art}/skills/otb-tile-generate/SKILL.md +0 -0
|
@@ -25,7 +25,10 @@
|
|
|
25
25
|
// Usage:
|
|
26
26
|
// node scripts/gds/skill-lint.js lint every skill Claude Code can load — .claude/skills/*
|
|
27
27
|
// plus the module-owned modules/*/skills/* that materialize
|
|
28
|
-
// into it; exit 1 on any error
|
|
28
|
+
// into it; exit 1 on any error. The listing total counts
|
|
29
|
+
// only the skills RESIDENT here: a skill owned by a module
|
|
30
|
+
// this checkout's config leaves off is linted, not counted
|
|
31
|
+
// (task 1004470)
|
|
29
32
|
// node scripts/gds/skill-lint.js <file...> lint specific files
|
|
30
33
|
// node scripts/gds/skill-lint.js --hook PostToolUse mode: read the hook JSON on stdin, lint the
|
|
31
34
|
// edited file if it is a SKILL.md, print findings, exit 0
|
|
@@ -146,11 +149,28 @@ function listSkillFiles(root = REPO_ROOT) {
|
|
|
146
149
|
return [...core, ...owned];
|
|
147
150
|
}
|
|
148
151
|
|
|
149
|
-
//
|
|
150
|
-
|
|
152
|
+
// The subset of listSkillFiles() that is actually RESIDENT for an instance — what
|
|
153
|
+
// claude-materialize.js would land in its .claude/skills/ (task 1004470). Linting still
|
|
154
|
+
// covers every file (a defect in an opt-in module's skill must not wait for the module
|
|
155
|
+
// to be switched on), but the listing cost is a per-session number, and a skill owned by
|
|
156
|
+
// a module the instance leaves off is never in that session's listing. Enablement is the
|
|
157
|
+
// materialiser's own rule (moduleSkillRoster: the instance's config/modules.json, else the
|
|
158
|
+
// module's `default`), read from there rather than restated. Fails open to the full list
|
|
159
|
+
// if the materialiser cannot load, so the number can only ever over-report.
|
|
160
|
+
function residentSkillFiles(root = REPO_ROOT, { instanceDir = root, files = listSkillFiles(root) } = {}) {
|
|
161
|
+
let off;
|
|
162
|
+
try { off = require('./claude-materialize.js').disabledModuleSkills({ coreRoot: root, instanceDir }); } catch (_) { return files; }
|
|
163
|
+
return files.filter((f) => !off.has(path.basename(path.dirname(f))));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Lint a set of files; returns per-file results plus the listing total vs budget. The
|
|
167
|
+
// listing counts `resident` (a list of paths) when given, else every linted file.
|
|
168
|
+
function lintSkills(files, { resident } = {}) {
|
|
151
169
|
const results = files.map((f) => lintSkillFile(fs.readFileSync(f, 'utf8'), { file: f }));
|
|
152
|
-
const
|
|
153
|
-
|
|
170
|
+
const counted = resident ? new Set(resident.map((f) => path.resolve(f))) : null;
|
|
171
|
+
const listed = counted ? results.filter((r) => counted.has(path.resolve(r.file))) : results;
|
|
172
|
+
const listingChars = listed.reduce((a, r) => a + r.listingChars, 0);
|
|
173
|
+
return { results, listingChars, listedSkills: listed.length, budgetChars: LISTING_BUDGET_CHARS, overBudget: listingChars > LISTING_BUDGET_CHARS };
|
|
154
174
|
}
|
|
155
175
|
|
|
156
176
|
// The PostToolUse hook's gate. It must match a module-owned SKILL.md too — that is the
|
|
@@ -170,7 +190,8 @@ function format(report, { relTo = REPO_ROOT } = {}) {
|
|
|
170
190
|
}
|
|
171
191
|
const errs = report.results.reduce((a, r) => a + r.errors.length, 0);
|
|
172
192
|
const warns = report.results.reduce((a, r) => a + r.warnings.length, 0);
|
|
173
|
-
|
|
193
|
+
const resident = report.listedSkills !== undefined && report.listedSkills !== report.results.length ? ` (the ${report.listedSkills} resident here; the rest belong to modules this instance leaves off)` : '';
|
|
194
|
+
out.push(`skill-lint: ${report.results.length} skill(s), ${errs} error(s), ${warns} warning(s); listing ${report.listingChars} chars${resident} (~${Math.round(report.listingChars / 4)} tokens) vs ~${report.budgetChars}-char budget${report.overBudget ? ' — OVER: entries get truncated and routing degrades' : ''}`);
|
|
174
195
|
return out.join('\n');
|
|
175
196
|
}
|
|
176
197
|
|
|
@@ -196,10 +217,11 @@ function main() {
|
|
|
196
217
|
return;
|
|
197
218
|
}
|
|
198
219
|
const files = argv.filter((a) => !a.startsWith('--'));
|
|
199
|
-
const
|
|
220
|
+
const all = files.length ? null : listSkillFiles();
|
|
221
|
+
const report = all ? lintSkills(all, { resident: residentSkillFiles(REPO_ROOT, { files: all }) }) : lintSkills(files.map((f) => path.resolve(f)));
|
|
200
222
|
if (json) console.log(JSON.stringify(report, null, 1)); else console.log(format(report));
|
|
201
223
|
process.exitCode = report.results.some((r) => r.errors.length) ? 1 : 0;
|
|
202
224
|
}
|
|
203
225
|
|
|
204
|
-
module.exports = { lintFrontmatter, lintSkillFile, lintSkills, listSkillFiles, splitFrontmatter, isSkillMd, format, DESC_WARN_CHARS, DESC_HARD_CHARS, LISTING_BUDGET_CHARS };
|
|
226
|
+
module.exports = { lintFrontmatter, lintSkillFile, lintSkills, listSkillFiles, residentSkillFiles, splitFrontmatter, isSkillMd, format, DESC_WARN_CHARS, DESC_HARD_CHARS, LISTING_BUDGET_CHARS };
|
|
205
227
|
if (require.main === module) main();
|
|
@@ -269,8 +269,9 @@ const MODULE_GLOBS = {
|
|
|
269
269
|
// than instance hosting and the web-surface precedent is one key per surface.
|
|
270
270
|
'public-landing': ['modules/public-landing/'],
|
|
271
271
|
// --- the design capability (task 1003324, ADR 0197): the design contract, its
|
|
272
|
-
// adapters + sync CLIs + validator, the /design playbook, and the
|
|
273
|
-
//
|
|
272
|
+
// adapters + sync CLIs + validator, the /design playbook, the kit and the style
|
|
273
|
+
// library (the design-style skills are the design-styles key since task
|
|
274
|
+
// 1004470). default-on,
|
|
274
275
|
// declaration-only (three skills, no routes/port/migrations). The three
|
|
275
276
|
// scripts/gds/ shims are core-side entry points over the module's CLIs —
|
|
276
277
|
// listed for task affinity (the memory/ideas precedent: a command travels with
|
|
@@ -302,6 +303,11 @@ const MODULE_GLOBS = {
|
|
|
302
303
|
'modules/game/', 'src/preview-server.js',
|
|
303
304
|
],
|
|
304
305
|
'art-pipeline': ['modules/art-pipeline/'],
|
|
306
|
+
// --- the two opt-in SKILL modules (task 1004470): declaration-only, default-off.
|
|
307
|
+
// Each owns only its own dir — the skills ship from modules/<key>/skills/,
|
|
308
|
+
// not .claude/skills/, so there is no core skill dir to list. ---
|
|
309
|
+
'pixel-art': ['modules/pixel-art/'],
|
|
310
|
+
'design-styles': ['modules/design-styles/'],
|
|
305
311
|
// --- the COPY half of the design contract: the artist's loop over user-facing
|
|
306
312
|
// TEXT (goal 1000074). ADR 0081 gave design TOKENS a repo-owned source of
|
|
307
313
|
// truth; text had none. R01 built the inventory (the copy-inventory
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.42'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -507,10 +507,12 @@ t('moduleSkillSources: a non-slug name never reaches path.join; a vendored dir w
|
|
|
507
507
|
t('materializeClaude: copied hooks carry NO founder origin/config-dir; a disabled-module skill is excluded', () => {
|
|
508
508
|
if (!existsSync(join(ROOT, '.claude', 'hooks'))) return;
|
|
509
509
|
const dir = mkdtempSync(join(tmpdir(), 'rb-e2e-'));
|
|
510
|
-
// Target instance: demo-branded, art
|
|
510
|
+
// Target instance: demo-branded, pixel-art OFF (so its otb-* skills must be dropped).
|
|
511
|
+
// Since task 1004470 those skills are the pixel-art module's (they were orphans in the
|
|
512
|
+
// core's .claude/skills/ that art-pipeline, which has no manifest here, never owned).
|
|
511
513
|
mkdirSync(join(dir, 'config'), { recursive: true });
|
|
512
514
|
writeFileSync(join(dir, 'config', 'branding.json'), JSON.stringify({ domains: { publicOrigin: 'https://demo.cloudbongos.com', buildersOrigin: 'https://demo.cloudbongos.com', statusOrigin: 'https://demo.cloudbongos.com', oauthOrigin: 'https://demo.cloudbongos.com' }, configDir: 'cloudbongos', envPrefix: 'BONGOS' }));
|
|
513
|
-
writeFileSync(join(dir, 'config', 'modules.json'), JSON.stringify({ modules: { 'art-pipeline': false } }));
|
|
515
|
+
writeFileSync(join(dir, 'config', 'modules.json'), JSON.stringify({ modules: { 'art-pipeline': false, 'pixel-art': false } }));
|
|
514
516
|
const res = m.materializeClaude({ coreRoot: ROOT, instanceDir: dir, dryRun: false });
|
|
515
517
|
// Founder core → demo: a branding delta exists and rules are built. The NEUTRAL
|
|
516
518
|
// core now carries its own dev-loop hooks (task 1003125), so this body runs there
|
|
@@ -531,12 +533,11 @@ t('materializeClaude: copied hooks carry NO founder origin/config-dir; a disable
|
|
|
531
533
|
}
|
|
532
534
|
assert.deepEqual(offenders, [], `founder origin/config-dir survived in: ${offenders.join(', ')}`);
|
|
533
535
|
|
|
534
|
-
// (b) art
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
}
|
|
536
|
+
// (b) pixel-art is off → its otb-* skills are excluded; a core skill remains. This used
|
|
537
|
+
// to self-scope to a founder core, the only place an art-pipeline manifest existed;
|
|
538
|
+
// the pixel-art module ships in the neutral core, so it now runs everywhere.
|
|
539
|
+
assert.ok(res.excludedSkills.includes('otb-tile-generate'), `pixel-art skill excluded (got ${res.excludedSkills.join(',')})`);
|
|
540
|
+
assert.ok(!existsSync(join(dir, '.claude', 'skills', 'otb-tile-generate')), 'excluded skill not written');
|
|
540
541
|
assert.ok(existsSync(join(dir, '.claude', 'skills', 'builder-ship')), 'a core skill is still materialized');
|
|
541
542
|
rmSync(dir, { recursive: true, force: true });
|
|
542
543
|
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// tests/module_assess_security.mjs — the Security assessment gate (task 1003792, ADR 0343 D2).
|
|
2
|
+
// No database and no network: the dependency audit is injected (and auditDependencies
|
|
3
|
+
// is driven with a fake npm), the store row goes to a fake pg client, and the tarball
|
|
4
|
+
// is a REAL packed + verified one. Pinned:
|
|
5
|
+
// - the outcome is only ever passed / failed / not_scored — never a number (D2 gate)
|
|
6
|
+
// - denied files, a non-registry dependency source and a high/critical advisory fail
|
|
7
|
+
// - an audit that cannot run is not_scored, never a pass (a gate that cannot run stays shut)
|
|
8
|
+
// - floating ranges and the maintenance posture are reported, never failed on
|
|
9
|
+
//
|
|
10
|
+
// Run: node tests/module_assess_security.mjs
|
|
11
|
+
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const { assessSecurity, auditDependencies, recordSecuritySignal, insertSecuritySignal, maintenancePosture, registrySpec } = require('../scripts/gds/module-assess-security.js');
|
|
21
|
+
const { packModule } = require('../scripts/gds/module-artifact.js');
|
|
22
|
+
const { relativeArtifactPath } = require('../src/bongos/module-store.js');
|
|
23
|
+
|
|
24
|
+
const file = (p, text = '') => ({ path: p, buf: Buffer.from(text) });
|
|
25
|
+
const MJ = (over = {}) => ({ key: 'weather', title: 'Weather', description: 'x', version: '1.2.0', coreVersion: '^1.0.0', contributes: {}, ...over });
|
|
26
|
+
const clean = async () => ({ ok: true, findings: [] });
|
|
27
|
+
const noAudit = async () => { throw new Error('the audit must not run for a module with no dependencies'); };
|
|
28
|
+
|
|
29
|
+
test('no dependencies and no denied files passes, with no number and no audit', async () => {
|
|
30
|
+
const s = await assessSecurity('weather', [file('module.json'), file('index.js')], MJ(), { audit: noAudit });
|
|
31
|
+
assert.equal(s.outcome, 'passed');
|
|
32
|
+
assert.equal(s.score, null, 'the gate never carries a score (core_265: security is pass/fail only)');
|
|
33
|
+
assert.equal(s.sample_size, 0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a credential-named file fails the gate on the publish denylist', async () => {
|
|
37
|
+
const s = await assessSecurity('weather', [file('module.json'), file('.env', 'X=1')], MJ(), { audit: noAudit });
|
|
38
|
+
assert.equal(s.outcome, 'failed');
|
|
39
|
+
assert.deepEqual(s.detail.failures.map((f) => f.check), ['denylist']);
|
|
40
|
+
assert.deepEqual(s.detail.failures[0].files, ['modules/weather/.env']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a dependency fetched from outside the registry fails, and is never handed to npm', async () => {
|
|
44
|
+
const specs = ['git+https://example.invalid/x.git', 'https://example.invalid/x.tgz', 'github:someone/thing', 'someone/thing',
|
|
45
|
+
'npm:other@1.0.0', 'file:../../../../etc', 'link:../x', 'workspace:*', '1.0.0 || file:../x', 'git@github.com:a/b', 'rm -rf /', 'a#b'];
|
|
46
|
+
for (const spec of specs) {
|
|
47
|
+
const s = await assessSecurity('weather', [file('module.json')],
|
|
48
|
+
MJ({ dependencies: { lodash: '^4.17.21', thing: spec } }), { audit: noAudit });
|
|
49
|
+
assert.equal(s.outcome, 'failed', spec);
|
|
50
|
+
assert.equal(s.detail.failures[0].check, 'dependency_source', spec);
|
|
51
|
+
assert.deepEqual(s.detail.failures[0].dependencies.map((d) => d.package), ['thing'], spec);
|
|
52
|
+
}
|
|
53
|
+
const badName = await assessSecurity('weather', [file('module.json')], MJ({ dependencies: { '../evil': '1.0.0' } }), { audit: noAudit });
|
|
54
|
+
assert.equal(badName.outcome, 'failed');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('registry specs: pinned, ranges and wildcards are allowed and told apart', () => {
|
|
58
|
+
const cases = [['1.2.3', 'pinned'], ['1.2.3-beta.1', 'pinned'], ['^14.26.4', 'floating range'], ['~1.2', 'floating range'],
|
|
59
|
+
['>=1.0.0 <2.0.0', 'floating range'], ['1.x', 'floating range'], ['1.0.0 - 2.0.0', 'floating range'], ['^1 || ^2', 'floating range'],
|
|
60
|
+
['*', 'wildcard'], ['latest', 'wildcard'], ['', 'wildcard']];
|
|
61
|
+
for (const [spec, kind] of cases) assert.deepEqual(registrySpec('lodash', spec), { ok: true, kind }, spec);
|
|
62
|
+
assert.equal(registrySpec('@scope/pkg', '^1.0.0').ok, true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a wildcard is noted separately from a floating range', async () => {
|
|
66
|
+
const s = await assessSecurity('weather', [file('module.json')], MJ({ dependencies: { a: '*', b: '^1.0.0', c: '1.0.0' } }), { audit: clean });
|
|
67
|
+
assert.equal(s.outcome, 'passed');
|
|
68
|
+
assert.deepEqual(s.detail.notes.filter((n) => n.check.startsWith('dependency')).map((n) => [n.check, n.package]),
|
|
69
|
+
[['dependency_wildcard', 'a'], ['dependency_range', 'b']]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a floating range is only noted', async () => {
|
|
73
|
+
const ok = await assessSecurity('weather', [file('module.json')], MJ({ dependencies: { lodash: '^4.17.21' } }), { audit: clean });
|
|
74
|
+
assert.equal(ok.outcome, 'passed');
|
|
75
|
+
assert.equal(ok.sample_size, 1);
|
|
76
|
+
assert.equal(ok.detail.notes.find((n) => n.check === 'dependency_range').package, 'lodash');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a high or critical advisory in a dependency fails the gate and names it', async () => {
|
|
80
|
+
const audit = async (deps) => {
|
|
81
|
+
assert.deepEqual(deps, { lodash: '^4.0.0' }, 'the audit sees exactly the declared dependencies');
|
|
82
|
+
return { ok: true, findings: [{ package: 'lodash', severity: 'critical', id: 'GHSA-x', summary: 'Prototype pollution', fix: 'lodash@4.17.21' }] };
|
|
83
|
+
};
|
|
84
|
+
const s = await assessSecurity('weather', [file('module.json')], MJ({ dependencies: { lodash: '^4.0.0' } }), { audit });
|
|
85
|
+
assert.equal(s.outcome, 'failed');
|
|
86
|
+
const v = s.detail.failures.find((f) => f.check === 'vulnerable_dependency');
|
|
87
|
+
assert.equal(v.advisories[0].id, 'GHSA-x');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('an audit that cannot run is not_scored — never a pass', async () => {
|
|
91
|
+
const s = await assessSecurity('weather', [file('module.json')], MJ({ dependencies: { lodash: '^4.0.0' } }),
|
|
92
|
+
{ audit: async () => ({ ok: false, reason: 'offline' }) });
|
|
93
|
+
assert.equal(s.outcome, 'not_scored');
|
|
94
|
+
assert.match(s.reason, /could not run/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('the maintenance posture is reported, never failed on', async () => {
|
|
98
|
+
assert.equal(maintenancePosture(MJ()), 'undeclared');
|
|
99
|
+
assert.equal(maintenancePosture(MJ({ maintenance: { status: 'orphaned' } })), 'orphaned');
|
|
100
|
+
for (const status of [undefined, 'deprecated', 'orphaned']) {
|
|
101
|
+
const mj = status ? MJ({ maintenance: { status, note: 'n' } }) : MJ();
|
|
102
|
+
const s = await assessSecurity('weather', [file('module.json')], mj, { audit: noAudit });
|
|
103
|
+
assert.equal(s.outcome, 'passed', String(status));
|
|
104
|
+
assert.equal(s.detail.notes.find((n) => n.check === 'maintenance').status, status || 'undeclared');
|
|
105
|
+
}
|
|
106
|
+
const kept = await assessSecurity('weather', [file('module.json')], MJ({ maintainer: 'a', maintenance: { status: 'maintained' } }), { audit: noAudit });
|
|
107
|
+
assert.equal(kept.detail.notes.length, 0, 'a maintained module has nothing to note');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---- auditDependencies: npm driven through a fake exec ----
|
|
111
|
+
|
|
112
|
+
function fakeNpm({ lockOk = true, auditOut }) {
|
|
113
|
+
const calls = [];
|
|
114
|
+
const exec = (cmd, argv, opts, cb) => {
|
|
115
|
+
const args = cmd === 'cmd.exe' ? argv.slice(argv.indexOf('npm') + 1) : argv;
|
|
116
|
+
calls.push({ args, pkg: JSON.parse(fs.readFileSync(path.join(opts.cwd, 'package.json'), 'utf8')) });
|
|
117
|
+
if (args[0] === 'install') {
|
|
118
|
+
if (lockOk) fs.writeFileSync(path.join(opts.cwd, 'package-lock.json'), '{}');
|
|
119
|
+
return cb(lockOk ? null : new Error('E404'), '', '');
|
|
120
|
+
}
|
|
121
|
+
return cb(new Error('exit 1'), auditOut, '');
|
|
122
|
+
};
|
|
123
|
+
return { calls, exec };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
test('the audit resolves metadata only (no install scripts) and reads npm audit\'s JSON', async () => {
|
|
127
|
+
const auditOut = JSON.stringify({ vulnerabilities: { lodash: { severity: 'high', via: [{ source: 1, title: 'Bad', severity: 'high', url: 'u' }], fixAvailable: true }, tiny: { severity: 'low', via: [] } } });
|
|
128
|
+
const npm = fakeNpm({ auditOut });
|
|
129
|
+
const a = await auditDependencies({ lodash: '^4.0.0' }, { exec: npm.exec });
|
|
130
|
+
assert.equal(a.ok, true);
|
|
131
|
+
assert.deepEqual(a.findings.map((f) => f.package), ['lodash'], 'only high and critical count');
|
|
132
|
+
const install = npm.calls[0].args;
|
|
133
|
+
assert.equal(install[0], 'install');
|
|
134
|
+
assert.ok(install.includes('--package-lock-only') && install.includes('--ignore-scripts'), 'no package code is downloaded or run');
|
|
135
|
+
assert.deepEqual(npm.calls[0].pkg.dependencies, { lodash: '^4.0.0' });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('an unresolvable dependency or an unreadable audit is a failed audit run, not a clean one', async () => {
|
|
139
|
+
assert.equal((await auditDependencies({ nope: '^1.0.0' }, { exec: fakeNpm({ lockOk: false }).exec })).ok, false);
|
|
140
|
+
assert.equal((await auditDependencies({ a: '^1.0.0' }, { exec: fakeNpm({ auditOut: 'not json' }).exec })).ok, false);
|
|
141
|
+
assert.equal((await auditDependencies({ a: '^1.0.0' }, { exec: fakeNpm({ auditOut: JSON.stringify({ error: { code: 'ENOAUDIT' } }) }).exec })).ok, false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ---- recordSecuritySignal: tarball in, one appended row out ----
|
|
145
|
+
|
|
146
|
+
function fakeDb(versionRow) {
|
|
147
|
+
const inserts = [];
|
|
148
|
+
return {
|
|
149
|
+
inserts,
|
|
150
|
+
async query(sql, params) {
|
|
151
|
+
if (/FROM store_module_versions/.test(sql)) return { rows: versionRow ? [versionRow] : [] };
|
|
152
|
+
if (/INSERT INTO module_assessment_signals/.test(sql)) {
|
|
153
|
+
assert.match(sql, /'security'/);
|
|
154
|
+
assert.doesNotMatch(sql, /UPDATE/i);
|
|
155
|
+
inserts.push(params);
|
|
156
|
+
return { rows: [{ id: 3, version_id: params[0], part: 'security', outcome: params[1] }] };
|
|
157
|
+
}
|
|
158
|
+
throw new Error(`unexpected query: ${sql}`);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function published(mj = MJ()) {
|
|
164
|
+
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-sec-src-'));
|
|
165
|
+
fs.mkdirSync(path.join(src, 'weather'), { recursive: true });
|
|
166
|
+
fs.writeFileSync(path.join(src, 'weather', 'module.json'), JSON.stringify(mj));
|
|
167
|
+
fs.writeFileSync(path.join(src, 'weather', 'index.js'), 'module.exports = {};\n');
|
|
168
|
+
const { tgz } = packModule('weather', { modulesDir: src, now: () => new Date('2026-09-30T00:00:00Z'), modeOf: () => '644' });
|
|
169
|
+
const storeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-sec-store-'));
|
|
170
|
+
const final = path.join(storeDir, 'weather', 'weather-1.2.0.tgz');
|
|
171
|
+
fs.mkdirSync(path.dirname(final), { recursive: true });
|
|
172
|
+
fs.writeFileSync(final, tgz);
|
|
173
|
+
return { storeDir, final, row: { id: 42, module_key: 'weather', version: '1.2.0', artifact_path: relativeArtifactPath(final, { dir: storeDir }) } };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
test('a published version is checked from its tarball and one security row is appended', async () => {
|
|
177
|
+
const { storeDir, row } = published(MJ({ dependencies: { lodash: '^4.17.21' } }));
|
|
178
|
+
const db = fakeDb(row);
|
|
179
|
+
let audited = null;
|
|
180
|
+
const res = await recordSecuritySignal(42, { db, storeDir, audit: async (d) => { audited = d; return { ok: true, findings: [] }; } });
|
|
181
|
+
assert.equal(res.ok, true);
|
|
182
|
+
assert.deepEqual(audited, { lodash: '^4.17.21' }, 'the dependencies come from the published module.json');
|
|
183
|
+
const [versionId, outcome, score, sample] = db.inserts[0];
|
|
184
|
+
assert.deepEqual([versionId, outcome, score, sample], [42, 'passed', null, 1]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('a missing or tampered tarball is not_scored, and an unknown version writes nothing', async () => {
|
|
188
|
+
const { storeDir, final, row } = published();
|
|
189
|
+
fs.writeFileSync(final, 'junk');
|
|
190
|
+
const db = fakeDb(row);
|
|
191
|
+
await recordSecuritySignal(42, { db, storeDir, audit: noAudit });
|
|
192
|
+
assert.equal(db.inserts[0][1], 'not_scored');
|
|
193
|
+
assert.doesNotMatch(db.inserts[0][4], /[\\/]/, 'no local path in the stored reason');
|
|
194
|
+
|
|
195
|
+
const none = fakeDb(null);
|
|
196
|
+
const res = await recordSecuritySignal(9, { db: none, audit: noAudit });
|
|
197
|
+
assert.equal(res.code, 'version_not_found');
|
|
198
|
+
assert.equal(none.inserts.length, 0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('insertSecuritySignal writes the security part with no score', async () => {
|
|
202
|
+
const seen = [];
|
|
203
|
+
await insertSecuritySignal(5, { outcome: 'failed', score: null, sample_size: 2, reason: 'r', detail: { failures: [] } },
|
|
204
|
+
{ db: { async query(sql, params) { seen.push(params); return { rows: [{}] }; } } });
|
|
205
|
+
assert.deepEqual(seen[0], [5, 'failed', null, 2, 'r', '{"failures":[]}']);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('a core script never requires a module file (the one-way rule)', () => {
|
|
209
|
+
const src = fs.readFileSync(new URL('../scripts/gds/module-assess-security.js', import.meta.url), 'utf8').replace(/\/\/.*$/gm, '');
|
|
210
|
+
assert.doesNotMatch(src, /require\([^)]*modules\//);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('the gate never runs the module\'s own code', () => {
|
|
214
|
+
const src = fs.readFileSync(new URL('../scripts/gds/module-assess-security.js', import.meta.url), 'utf8').replace(/\/\/.*$/gm, '');
|
|
215
|
+
assert.doesNotMatch(src, /process\.execPath|require\(\s*path\.join|import\(/, 'no node process or require over module files');
|
|
216
|
+
});
|
package/tests/module_loader.mjs
CHANGED
|
@@ -276,7 +276,7 @@ test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49
|
|
|
276
276
|
// That is precisely how the agents regression reached an open PR (task 1002485).
|
|
277
277
|
loader._reset();
|
|
278
278
|
const snap = loader.loaderSnapshot();
|
|
279
|
-
assert.deepEqual(snap.loaded, ['agents', 'autonomy', 'builder-settings', 'copy-desk', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'npm-release', 'onboarding', 'platform-identity', 'provisioning', 'public-landing', 'render-deploy', 'security', 'sessions', 'specialities', 'status-ui', 'ui-design']);
|
|
279
|
+
assert.deepEqual(snap.loaded, ['agents', 'autonomy', 'builder-settings', 'copy-desk', 'design-styles', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'npm-release', 'onboarding', 'pixel-art', 'platform-identity', 'provisioning', 'public-landing', 'render-deploy', 'security', 'sessions', 'specialities', 'status-ui', 'ui-design']);
|
|
280
280
|
assert.deepEqual(snap.errors, []);
|
|
281
281
|
assert.deepEqual(snap.skipped, []);
|
|
282
282
|
loader._reset();
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// tests/opt_in_skill_modules.mjs — the two opt-in skill modules of task 1004470 (owner
|
|
2
|
+
// ruling on blocker 1000139), pinned on the REAL tree rather than a fixture core:
|
|
3
|
+
//
|
|
4
|
+
// pixel-art (default: false) — the five otb-* skills, orphans in .claude/skills/
|
|
5
|
+
// until now, carrying skill text only and never a copyrighted asset;
|
|
6
|
+
// design-styles (default: false) — the thirteen design-style skills split out of
|
|
7
|
+
// ui-design, which keeps /design + the sync skills and stays default-on.
|
|
8
|
+
//
|
|
9
|
+
// Both shapes the task names: a default-config instance materialises NEITHER module's
|
|
10
|
+
// skills, and an instance that turns both on materialises all eighteen with nothing
|
|
11
|
+
// refused by the vendoring gate. Plus the reason the split exists — skill-lint's resident
|
|
12
|
+
// listing for the default shape is ~6,800 chars smaller — and the publish allowlist that
|
|
13
|
+
// lets a released core carry the modules at all. DB-free.
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const materialize = require(path.join(ROOT, 'scripts', 'gds', 'claude-materialize.js'));
|
|
25
|
+
const lint = require(path.join(ROOT, 'scripts', 'gds', 'skill-lint.js'));
|
|
26
|
+
const publish = require(path.join(ROOT, 'scripts', 'gds', 'publish-manifest.js'));
|
|
27
|
+
const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
28
|
+
|
|
29
|
+
const PIXEL_ART = ['otb-tile-generate', 'otb-design-review', 'otb-character-review', 'otb-feedback-capture', 'otb-figma-sync'];
|
|
30
|
+
const DESIGN_STYLES = ['design-taste-frontend', 'high-end-visual-design', 'redesign-existing-projects', 'impeccable', 'style', 'minimalist-ui', 'industrial-brutalist-ui', 'gpt-taste', 'stitch-design-taste', 'image-to-code', 'imagegen-frontend-web', 'imagegen-frontend-mobile', 'brandkit'];
|
|
31
|
+
const ALL = [...PIXEL_ART, ...DESIGN_STYLES];
|
|
32
|
+
const UI_DESIGN_KEEPS = ['design', 'design-sync', 'figma-design-sync'];
|
|
33
|
+
|
|
34
|
+
function instance(modules) {
|
|
35
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opt-in-skills-'));
|
|
36
|
+
if (modules) {
|
|
37
|
+
fs.mkdirSync(path.join(dir, 'config'), { recursive: true });
|
|
38
|
+
fs.writeFileSync(path.join(dir, 'config', 'modules.json'), JSON.stringify({ modules }));
|
|
39
|
+
}
|
|
40
|
+
return dir;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test('the two manifests: default off, each declares exactly its skills, and ui-design keeps only its three', () => {
|
|
44
|
+
const pa = readJson(path.join(ROOT, 'modules', 'pixel-art', 'module.json'));
|
|
45
|
+
const ds = readJson(path.join(ROOT, 'modules', 'design-styles', 'module.json'));
|
|
46
|
+
const ui = readJson(path.join(ROOT, 'modules', 'ui-design', 'module.json'));
|
|
47
|
+
assert.equal(pa.key, 'pixel-art');
|
|
48
|
+
assert.equal(pa.default, false, 'pixel-art is opt-in');
|
|
49
|
+
assert.deepEqual([...pa.contributes.skills].sort(), [...PIXEL_ART].sort());
|
|
50
|
+
assert.equal(ds.key, 'design-styles');
|
|
51
|
+
assert.equal(ds.default, false, 'design-styles is opt-in');
|
|
52
|
+
assert.deepEqual([...ds.contributes.skills].sort(), [...DESIGN_STYLES].sort());
|
|
53
|
+
assert.equal(ui.default, true, 'ui-design stays default-on');
|
|
54
|
+
assert.deepEqual([...ui.contributes.skills].sort(), [...UI_DESIGN_KEEPS].sort(), 'ui-design keeps /design and the two sync skills, nothing else');
|
|
55
|
+
for (const m of [pa, ds]) {
|
|
56
|
+
for (const k of ['routes', 'pollers', 'migrations', 'uiSections']) assert.ok(!(k in m.contributes), `${m.key}: declaration-only, contributes no ${k}`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('design-taste-frontend-v1 is gone everywhere a skill can live', () => {
|
|
61
|
+
for (const dir of [path.join(ROOT, '.claude', 'skills'), ...fs.readdirSync(path.join(ROOT, 'modules')).map((m) => path.join(ROOT, 'modules', m, 'skills'))]) {
|
|
62
|
+
assert.ok(!fs.existsSync(path.join(dir, 'design-taste-frontend-v1')), `${path.relative(ROOT, dir)}: no design-taste-frontend-v1`);
|
|
63
|
+
}
|
|
64
|
+
const declared = fs.readdirSync(path.join(ROOT, 'modules')).flatMap((m) => {
|
|
65
|
+
const p = path.join(ROOT, 'modules', m, 'module.json');
|
|
66
|
+
return fs.existsSync(p) ? ((readJson(p).contributes || {}).skills || []) : [];
|
|
67
|
+
});
|
|
68
|
+
assert.ok(!declared.includes('design-taste-frontend-v1'), 'no module declares it');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('the eighteen skills left the core .claude/skills/ and live only in their module', () => {
|
|
72
|
+
for (const name of PIXEL_ART) {
|
|
73
|
+
assert.ok(!execTracked(name), `${name}: not a tracked core skill any more`);
|
|
74
|
+
assert.ok(fs.existsSync(path.join(ROOT, 'modules', 'pixel-art', 'skills', name, 'SKILL.md')), `${name}: modules/pixel-art/skills/${name}/SKILL.md`);
|
|
75
|
+
}
|
|
76
|
+
for (const name of DESIGN_STYLES) {
|
|
77
|
+
assert.ok(fs.existsSync(path.join(ROOT, 'modules', 'design-styles', 'skills', name, 'SKILL.md')), `${name}: modules/design-styles/skills/${name}/SKILL.md`);
|
|
78
|
+
assert.ok(!fs.existsSync(path.join(ROOT, 'modules', 'ui-design', 'skills', name)), `${name}: no copy left under ui-design`);
|
|
79
|
+
}
|
|
80
|
+
for (const f of ['policy.json', 'PREAMBLE.md', 'README.md']) assert.ok(fs.existsSync(path.join(ROOT, 'modules', 'design-styles', 'skills', f)), `design-styles carries skills/${f}`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// A copy materialised into the core checkout (--module-skills-only) is untracked and
|
|
84
|
+
// self-ignored; only a TRACKED .claude/skills/<name> would mean the move did not happen.
|
|
85
|
+
function execTracked(name) {
|
|
86
|
+
const { spawnSync } = require('node:child_process');
|
|
87
|
+
return spawnSync('git', ['-C', ROOT, 'ls-files', '--error-unmatch', '--', `.claude/skills/${name}/SKILL.md`], { encoding: 'utf8' }).status === 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test('pixel-art carries skill text only — never a reference map, a palette, a rubric or generated art', () => {
|
|
91
|
+
const files = [];
|
|
92
|
+
const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else files.push(path.relative(path.join(ROOT, 'modules', 'pixel-art'), p).split(path.sep).join('/')); } };
|
|
93
|
+
walk(path.join(ROOT, 'modules', 'pixel-art'));
|
|
94
|
+
const allowed = new Set(['module.json', 'CLAUDE.md', ...PIXEL_ART.map((n) => `skills/${n}/SKILL.md`)]);
|
|
95
|
+
assert.deepEqual(files.filter((f) => !allowed.has(f)), [], 'nothing but the manifest, its CLAUDE.md and the five SKILL.md files (ADR 0098: the art assets stay in the never-published art-pipeline)');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('DEFAULT config: a fresh instance materialises neither module\'s skills, and still gets /design', () => {
|
|
99
|
+
const dir = instance(null);
|
|
100
|
+
const res = materialize.materializeClaude({ coreRoot: ROOT, instanceDir: dir, dryRun: false });
|
|
101
|
+
for (const name of ALL) {
|
|
102
|
+
assert.ok(!fs.existsSync(path.join(dir, '.claude', 'skills', name)), `${name}: not materialised by default`);
|
|
103
|
+
assert.ok(res.excludedSkills.includes(name), `${name}: reported as excluded (module off)`);
|
|
104
|
+
}
|
|
105
|
+
for (const name of UI_DESIGN_KEEPS) assert.ok(fs.existsSync(path.join(dir, '.claude', 'skills', name, 'SKILL.md')), `${name}: ui-design's own skill still lands`);
|
|
106
|
+
assert.ok(fs.existsSync(path.join(dir, '.claude', 'skills', 'builder-ship', 'SKILL.md')), 'a core skill still lands');
|
|
107
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('ENABLED config: pixel-art + design-styles on → all eighteen materialise, none refused by the vendoring gate', () => {
|
|
111
|
+
const dir = instance({ 'pixel-art': true, 'design-styles': true });
|
|
112
|
+
const res = materialize.materializeClaude({ coreRoot: ROOT, instanceDir: dir, dryRun: false });
|
|
113
|
+
assert.deepEqual(res.notes.filter((n) => n.includes('refused')), [], 'the vendoring gate refused nothing');
|
|
114
|
+
for (const name of ALL) {
|
|
115
|
+
assert.ok(fs.existsSync(path.join(dir, '.claude', 'skills', name, 'SKILL.md')), `${name}: materialised`);
|
|
116
|
+
assert.ok(!res.excludedSkills.includes(name), `${name}: not excluded`);
|
|
117
|
+
}
|
|
118
|
+
assert.ok(fs.existsSync(path.join(dir, '.claude', 'skills', 'impeccable', 'reference', 'audit.md')), 'impeccable lands as a tree, its playbooks included');
|
|
119
|
+
// the gate itself, applied to each source the materialiser resolved for the two modules
|
|
120
|
+
const roster = materialize.moduleSkillRoster({ coreRoot: ROOT, instanceDir: dir });
|
|
121
|
+
const sources = materialize.moduleSkillSources({ roster });
|
|
122
|
+
assert.deepEqual(sources.refused.filter((r) => ['pixel-art', 'design-styles'].includes(r.module)), []);
|
|
123
|
+
const mine = sources.filter((s) => ['pixel-art', 'design-styles'].includes(s.module));
|
|
124
|
+
assert.deepEqual(mine.map((s) => s.name).sort(), [...ALL].sort(), 'the materialiser resolves exactly the eighteen');
|
|
125
|
+
const policy = readJson(path.join(ROOT, 'modules', 'design-styles', 'skills', 'policy.json'));
|
|
126
|
+
for (const s of mine) if (s.vendored) assert.ok(materialize.vendoredSkillGate(s.dir, policy).ok, `${s.name}: a vendored copy passes the gate`);
|
|
127
|
+
// and the instance's provenance record names each with its module, so switching one off later withdraws it
|
|
128
|
+
const record = readJson(path.join(dir, '.claude', 'skills', materialize.INSTANCE_SKILLS_MANIFEST)).skills;
|
|
129
|
+
for (const name of PIXEL_ART) assert.equal(record[name].module, 'pixel-art', `${name}: recorded under pixel-art`);
|
|
130
|
+
for (const name of DESIGN_STYLES) assert.equal(record[name].module, 'design-styles', `${name}: recorded under design-styles`);
|
|
131
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('switching the modules back off withdraws what an earlier run landed', () => {
|
|
135
|
+
const dir = instance({ 'pixel-art': true, 'design-styles': true });
|
|
136
|
+
materialize.materializeClaude({ coreRoot: ROOT, instanceDir: dir, dryRun: false });
|
|
137
|
+
fs.writeFileSync(path.join(dir, 'config', 'modules.json'), JSON.stringify({ modules: { 'pixel-art': false, 'design-styles': false } }));
|
|
138
|
+
const res = materialize.materializeClaude({ coreRoot: ROOT, instanceDir: dir, dryRun: false, overwrite: true });
|
|
139
|
+
for (const name of ALL) assert.ok(!fs.existsSync(path.join(dir, '.claude', 'skills', name)), `${name}: withdrawn`);
|
|
140
|
+
assert.ok(res.withdrawnSkills.length >= ALL.length, 'every one is reported withdrawn');
|
|
141
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('skill-lint lints all eighteen but counts only the resident ones: the default shape is ~6,800 chars smaller', () => {
|
|
145
|
+
const files = lint.listSkillFiles(ROOT);
|
|
146
|
+
const names = files.map((f) => path.basename(path.dirname(f)));
|
|
147
|
+
for (const n of ALL) assert.ok(names.includes(n), `${n}: still linted though its module is off`);
|
|
148
|
+
const off = instance(null);
|
|
149
|
+
const on = instance({ 'pixel-art': true, 'design-styles': true });
|
|
150
|
+
const def = lint.lintSkills(files, { resident: lint.residentSkillFiles(ROOT, { instanceDir: off, files }) });
|
|
151
|
+
const all = lint.lintSkills(files, { resident: lint.residentSkillFiles(ROOT, { instanceDir: on, files }) });
|
|
152
|
+
const delta = all.listingChars - def.listingChars;
|
|
153
|
+
assert.ok(delta >= 6000, `the two modules take ~6,800 chars out of a default listing (measured ${delta})`);
|
|
154
|
+
assert.equal(all.listedSkills - def.listedSkills, ALL.length, 'exactly the eighteen leave the default listing');
|
|
155
|
+
assert.equal(lint.lintSkills(files).listingChars, all.listingChars, 'with no resident list, every linted file is counted (the old behaviour)');
|
|
156
|
+
for (const d of [off, on]) fs.rmSync(d, { recursive: true, force: true });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('a released core carries both modules (shipped is not enabled), and art-pipeline stays excluded', () => {
|
|
160
|
+
assert.ok(publish.isPublishable('modules/pixel-art/module.json'), 'pixel-art publishes');
|
|
161
|
+
assert.ok(publish.isPublishable('modules/pixel-art/skills/otb-tile-generate/SKILL.md'), 'its skills publish');
|
|
162
|
+
assert.ok(publish.isPublishable('modules/design-styles/module.json'), 'design-styles publishes');
|
|
163
|
+
assert.ok(publish.isPublishable('modules/design-styles/skills/impeccable/reference/audit.md'), 'its skill trees publish');
|
|
164
|
+
assert.ok(!publish.isPublishable('modules/art-pipeline/references/x.png'), 'the copyrighted reference maps never publish (ADR 0098)');
|
|
165
|
+
});
|
package/tests/skill_lint.mjs
CHANGED
|
@@ -106,14 +106,38 @@ test('the lister reaches module-owned skills, and a core copy of the same name w
|
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
test('every module-owned skill this repo ships parses too — the 14 that did not on 2026-09-04 (task 1003587)', () => {
|
|
109
|
-
|
|
109
|
+
// Those skills moved to the opt-in design-styles module (task 1004470, which also deleted
|
|
110
|
+
// design-taste-frontend-v1, leaving 13); an OFF module's skills are still linted.
|
|
111
|
+
const dir = path.join(ROOT, 'modules', 'design-styles', 'skills');
|
|
110
112
|
const files = lint.listSkillFiles(ROOT).filter((f) => f.startsWith(dir) || fs.existsSync(path.join(dir, path.basename(path.dirname(f)), 'SKILL.md')));
|
|
111
|
-
assert.ok(files.length >=
|
|
113
|
+
assert.ok(files.length >= 13, `the design-styles module ships 13+ skills; found ${files.length}`);
|
|
112
114
|
const report = lint.lintSkills(files);
|
|
113
115
|
const broken = report.results.filter((r) => r.errors.length).map((r) => path.relative(ROOT, r.file) + ': ' + r.errors.join('; '));
|
|
114
116
|
assert.deepEqual(broken, [], broken.join('\n'));
|
|
115
117
|
});
|
|
116
118
|
|
|
119
|
+
test('the listing counts only RESIDENT skills: an off module\'s skill is linted but not counted, an on one is both (task 1004470)', () => {
|
|
120
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-lint-resident-'));
|
|
121
|
+
const skill = (n) => `---\nname: ${n}\ndescription: ${'x'.repeat(100)}\n---\nbody\n`;
|
|
122
|
+
const put = (rel, text) => { const p = path.join(root, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, text); };
|
|
123
|
+
put('.claude/skills/core-one/SKILL.md', skill('core-one'));
|
|
124
|
+
put('modules/offmod/module.json', JSON.stringify({ key: 'offmod', default: false, contributes: { skills: ['off-one'] } }));
|
|
125
|
+
put('modules/offmod/skills/off-one/SKILL.md', skill('off-one'));
|
|
126
|
+
put('modules/onmod/module.json', JSON.stringify({ key: 'onmod', default: true, contributes: { skills: ['on-one'] } }));
|
|
127
|
+
put('modules/onmod/skills/on-one/SKILL.md', skill('on-one'));
|
|
128
|
+
const files = lint.listSkillFiles(root);
|
|
129
|
+
assert.equal(files.length, 3, 'all three are linted');
|
|
130
|
+
const resident = lint.residentSkillFiles(root, { files }).map((f) => path.basename(path.dirname(f))).sort();
|
|
131
|
+
assert.deepEqual(resident, ['core-one', 'on-one'], 'the default-off module\'s skill is not resident');
|
|
132
|
+
const report = lint.lintSkills(files, { resident: lint.residentSkillFiles(root, { files }) });
|
|
133
|
+
assert.equal(report.results.length, 3);
|
|
134
|
+
assert.equal(report.listedSkills, 2);
|
|
135
|
+
assert.equal(report.listingChars, ('core-one'.length + 100) + ('on-one'.length + 100), 'the listing is the two resident entries');
|
|
136
|
+
put('config/modules.json', JSON.stringify({ modules: { offmod: true, onmod: false } }));
|
|
137
|
+
assert.deepEqual(lint.residentSkillFiles(root, { files }).map((f) => path.basename(path.dirname(f))).sort(), ['core-one', 'off-one'], 'the instance\'s config/modules.json wins over each module\'s default');
|
|
138
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
139
|
+
});
|
|
140
|
+
|
|
117
141
|
test('every committed skill parses (the ten that did not on 2026-09-03 must stay fixed)', () => {
|
|
118
142
|
const report = lint.lintSkills(lint.listSkillFiles(ROOT));
|
|
119
143
|
const broken = report.results.filter((r) => r.errors.length).map((r) => path.relative(ROOT, r.file) + ': ' + r.errors.join('; '));
|