@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.
Files changed (74) hide show
  1. package/.bongos-core.json +265 -235
  2. package/.claude/skills/design/SKILL.md +6 -5
  3. package/docs/adr/0081-tool-agnostic-design-layer.md +1 -1
  4. package/docs/adr/0198-third-party-skill-vendoring-policy.md +2 -2
  5. package/docs/architecture.md +1 -1
  6. package/docs/file-map.md +51 -54
  7. package/docs/module-api-changelog.md +4 -0
  8. package/docs/modules-contract.md +7 -5
  9. package/docs/onboarding/slash-commands.md +1 -1
  10. package/modules/design-styles/CLAUDE.md +15 -0
  11. package/modules/design-styles/module.json +12 -0
  12. package/modules/{ui-design → design-styles}/skills/PREAMBLE.md +2 -2
  13. package/modules/{ui-design → design-styles}/skills/README.md +6 -6
  14. package/modules/{ui-design → design-styles}/skills/brandkit/SKILL.md +2 -2
  15. package/modules/{ui-design → design-styles}/skills/design-taste-frontend/SKILL.md +2 -2
  16. package/modules/{ui-design → design-styles}/skills/gpt-taste/SKILL.md +2 -2
  17. package/modules/{ui-design → design-styles}/skills/high-end-visual-design/SKILL.md +2 -2
  18. package/modules/{ui-design → design-styles}/skills/image-to-code/SKILL.md +2 -2
  19. package/modules/{ui-design → design-styles}/skills/imagegen-frontend-mobile/SKILL.md +2 -2
  20. package/modules/{ui-design → design-styles}/skills/imagegen-frontend-web/SKILL.md +2 -2
  21. package/modules/{ui-design → design-styles}/skills/impeccable/SKILL.md +3 -3
  22. package/modules/{ui-design → design-styles}/skills/industrial-brutalist-ui/SKILL.md +2 -2
  23. package/modules/{ui-design → design-styles}/skills/minimalist-ui/SKILL.md +2 -2
  24. package/modules/{ui-design → design-styles}/skills/policy.json +1 -1
  25. package/modules/{ui-design → design-styles}/skills/redesign-existing-projects/SKILL.md +2 -2
  26. package/modules/{ui-design → design-styles}/skills/stitch-design-taste/SKILL.md +2 -2
  27. package/modules/{ui-design → design-styles}/skills/style/SKILL.md +2 -2
  28. package/modules/pixel-art/CLAUDE.md +15 -0
  29. package/modules/pixel-art/module.json +12 -0
  30. package/modules/provisioning/starter-bundles.js +15 -0
  31. package/modules/ui-design/kit/serve.js +2 -0
  32. package/modules/ui-design/module.json +3 -3
  33. package/package-lock.json +2 -2
  34. package/package.json +1 -1
  35. package/release-notes.json +12 -0
  36. package/scripts/gds/doc-cli-guard.js +1 -1
  37. package/scripts/gds/fitness-ratchets.js +4 -1
  38. package/scripts/gds/fitness.js +2 -1
  39. package/scripts/gds/module-artifact.js +1 -1
  40. package/scripts/gds/module-assess-security.js +251 -0
  41. package/scripts/gds/publish-manifest.js +2 -0
  42. package/scripts/gds/skill-lint.js +30 -8
  43. package/src/bongos/module-scope-map.js +8 -2
  44. package/src/module-api.js +1 -1
  45. package/tests/claude_materialize.mjs +9 -8
  46. package/tests/module_assess_security.mjs +216 -0
  47. package/tests/module_loader.mjs +1 -1
  48. package/tests/opt_in_skill_modules.mjs +165 -0
  49. package/tests/skill_lint.mjs +26 -2
  50. package/tests/ui_design_skills.mjs +29 -17
  51. package/modules/ui-design/skills/design-taste-frontend-v1/SKILL.md +0 -135
  52. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/adapt.md +0 -0
  53. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/animate.md +0 -0
  54. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/audit.md +0 -0
  55. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/bolder.md +0 -0
  56. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/clarify.md +0 -0
  57. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/colorize.md +0 -0
  58. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/critique.md +0 -0
  59. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/distill.md +0 -0
  60. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/document.md +0 -0
  61. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/extract.md +0 -0
  62. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/harden.md +0 -0
  63. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/layout.md +0 -0
  64. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/onboard.md +0 -0
  65. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/optimize.md +0 -0
  66. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/polish.md +0 -0
  67. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/quieter.md +0 -0
  68. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/shape.md +0 -0
  69. /package/modules/{ui-design → design-styles}/skills/impeccable/reference/typeset.md +0 -0
  70. /package/{.claude → modules/pixel-art}/skills/otb-character-review/SKILL.md +0 -0
  71. /package/{.claude → modules/pixel-art}/skills/otb-design-review/SKILL.md +0 -0
  72. /package/{.claude → modules/pixel-art}/skills/otb-feedback-capture/SKILL.md +0 -0
  73. /package/{.claude → modules/pixel-art}/skills/otb-figma-sync/SKILL.md +0 -0
  74. /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
- // Lint a set of files; returns per-file results plus the listing total vs budget.
150
- function lintSkills(files) {
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 listingChars = results.reduce((a, r) => a + r.listingChars, 0);
153
- return { results, listingChars, budgetChars: LISTING_BUDGET_CHARS, overBudget: listingChars > LISTING_BUDGET_CHARS };
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
- out.push(`skill-lint: ${report.results.length} skill(s), ${errs} error(s), ${warns} warning(s); listing ${report.listingChars} chars (~${Math.round(report.listingChars / 4)} tokens) vs ~${report.budgetChars}-char budget${report.overBudget ? ' — OVER: entries get truncated and routing degrades' : ''}`);
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 report = lintSkills(files.length ? files.map((f) => path.resolve(f)) : listSkillFiles());
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 reserved homes
273
- // for the kit / the vendored UI skills / the style library. default-on,
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.40'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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-pipeline OFF (so its otb-* skills must be dropped).
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-pipeline is off → its otb-* skills are excluded; a core skill remains.
535
- // (The exclusion map, like the rebrand rules, resolves on a founder core.)
536
- if (founderDelta) {
537
- assert.ok(res.excludedSkills.includes('otb-tile-generate'), `art-pipeline skill excluded (got ${res.excludedSkills.join(',')})`);
538
- assert.ok(!existsSync(join(dir, '.claude', 'skills', 'otb-tile-generate')), 'excluded skill not written');
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
+ });
@@ -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
+ });
@@ -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
- const dir = path.join(ROOT, 'modules', 'ui-design', 'skills');
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 >= 14, `the ui-design module ships 14+ skills; found ${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('; '));