@productbrain/cli 0.1.0-beta.15 → 0.1.0-beta.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -0
- package/dist/__tests__/capture.test.js +49 -0
- package/dist/__tests__/capture.test.js.map +1 -1
- package/dist/__tests__/handshake.test.js +18 -1
- package/dist/__tests__/handshake.test.js.map +1 -1
- package/dist/__tests__/orient.test.d.ts +2 -0
- package/dist/__tests__/orient.test.d.ts.map +1 -0
- package/dist/__tests__/orient.test.js +143 -0
- package/dist/__tests__/orient.test.js.map +1 -0
- package/dist/__tests__/promote.test.js +39 -5
- package/dist/__tests__/promote.test.js.map +1 -1
- package/dist/__tests__/repo-detect.test.js +97 -1
- package/dist/__tests__/repo-detect.test.js.map +1 -1
- package/dist/__tests__/update.test.js +29 -4
- package/dist/__tests__/update.test.js.map +1 -1
- package/dist/commands/capture.d.ts.map +1 -1
- package/dist/commands/capture.js +62 -5
- package/dist/commands/capture.js.map +1 -1
- package/dist/commands/codex-prep.d.ts +12 -0
- package/dist/commands/codex-prep.d.ts.map +1 -0
- package/dist/commands/codex-prep.js +118 -0
- package/dist/commands/codex-prep.js.map +1 -0
- package/dist/commands/handshake.d.ts +6 -0
- package/dist/commands/handshake.d.ts.map +1 -1
- package/dist/commands/handshake.js +154 -11
- package/dist/commands/handshake.js.map +1 -1
- package/dist/commands/orient.d.ts +100 -1
- package/dist/commands/orient.d.ts.map +1 -1
- package/dist/commands/orient.js +3 -1
- package/dist/commands/orient.js.map +1 -1
- package/dist/commands/promote.d.ts.map +1 -1
- package/dist/commands/promote.js +28 -1
- package/dist/commands/promote.js.map +1 -1
- package/dist/commands/update.d.ts +1 -0
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +23 -2
- package/dist/commands/update.js.map +1 -1
- package/dist/formatters/capture.d.ts +3 -3
- package/dist/formatters/capture.d.ts.map +1 -1
- package/dist/formatters/capture.js +2 -1
- package/dist/formatters/capture.js.map +1 -1
- package/dist/formatters/entry.d.ts +0 -4
- package/dist/formatters/entry.d.ts.map +1 -1
- package/dist/formatters/entry.js +16 -13
- package/dist/formatters/entry.js.map +1 -1
- package/dist/formatters/handshake.d.ts +2 -0
- package/dist/formatters/handshake.d.ts.map +1 -1
- package/dist/formatters/handshake.js +10 -1
- package/dist/formatters/handshake.js.map +1 -1
- package/dist/formatters/orient.d.ts +99 -1
- package/dist/formatters/orient.d.ts.map +1 -1
- package/dist/formatters/orient.js +121 -17
- package/dist/formatters/orient.js.map +1 -1
- package/dist/formatters/search.d.ts +0 -4
- package/dist/formatters/search.d.ts.map +1 -1
- package/dist/formatters/search.js +4 -1
- package/dist/formatters/search.js.map +1 -1
- package/dist/formatters/update.d.ts.map +1 -1
- package/dist/formatters/update.js +2 -0
- package/dist/formatters/update.js.map +1 -1
- package/dist/generators/adapters.d.ts +1 -0
- package/dist/generators/adapters.d.ts.map +1 -1
- package/dist/generators/adapters.js +48 -0
- package/dist/generators/adapters.js.map +1 -1
- package/dist/generators/adapters.test.d.ts +2 -0
- package/dist/generators/adapters.test.d.ts.map +1 -0
- package/dist/generators/adapters.test.js +27 -0
- package/dist/generators/adapters.test.js.map +1 -0
- package/dist/generators/portable-knowledge.d.ts +58 -5
- package/dist/generators/portable-knowledge.d.ts.map +1 -1
- package/dist/generators/portable-knowledge.js +223 -13
- package/dist/generators/portable-knowledge.js.map +1 -1
- package/dist/generators/portable-knowledge.test.js +529 -1
- package/dist/generators/portable-knowledge.test.js.map +1 -1
- package/dist/index.js +24 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/repo-detect.d.ts +19 -0
- package/dist/lib/repo-detect.d.ts.map +1 -1
- package/dist/lib/repo-detect.js +25 -0
- package/dist/lib/repo-detect.js.map +1 -1
- package/dist/lib/strip.d.ts +1 -0
- package/dist/lib/strip.d.ts.map +1 -1
- package/dist/lib/strip.js +15 -0
- package/dist/lib/strip.js.map +1 -1
- package/package.json +3 -2
- package/templates/general/code-integrity.md +11 -0
- package/templates/general/getting-started.md +12 -0
- package/templates/node-ts/code-integrity.md +13 -0
- package/templates/node-ts/testing.md +12 -0
- package/templates/python/code-integrity.md +13 -0
- package/templates/python/testing.md +12 -0
|
@@ -39,7 +39,7 @@ vi.mock('fs', () => ({
|
|
|
39
39
|
return [...files];
|
|
40
40
|
}),
|
|
41
41
|
}));
|
|
42
|
-
import { readCanonicalSkills, shouldEmitToTarget, stripTransportSections, filterByLevel, generateCursorSkill, generateClaudeSkillRouter, } from './portable-knowledge.js';
|
|
42
|
+
import { readCanonicalSkills, readCanonicalRules, shouldEmitToTarget, stripTransportSections, filterByLevel, evaluateConditions, generateCursorSkill, generateCursorRule, generateClaudeRule, generateCodexSkill, generateCodexSkillIndex, generateClaudeSkillRouter, } from './portable-knowledge.js';
|
|
43
43
|
const PB_DIR = '/tmp/pb-test/.productbrain';
|
|
44
44
|
describe('readCanonicalSkills', () => {
|
|
45
45
|
beforeEach(() => {
|
|
@@ -134,6 +134,7 @@ describe('shouldEmitToTarget', () => {
|
|
|
134
134
|
expect(shouldEmitToTarget(skill, 'claude')).toBe(true);
|
|
135
135
|
expect(shouldEmitToTarget(skill, 'cursor')).toBe(true);
|
|
136
136
|
expect(shouldEmitToTarget(skill, 'copilot')).toBe(true);
|
|
137
|
+
expect(shouldEmitToTarget(skill, 'codex')).toBe(true);
|
|
137
138
|
});
|
|
138
139
|
it('returns true when target is in the list', () => {
|
|
139
140
|
const skill = {
|
|
@@ -292,6 +293,77 @@ Universal content.`,
|
|
|
292
293
|
expect(output).not.toContain('<!-- transport:');
|
|
293
294
|
});
|
|
294
295
|
});
|
|
296
|
+
describe('generateCodexSkill / generateCodexSkillIndex', () => {
|
|
297
|
+
it('strips non-codex transport sections from generated Codex skill output', () => {
|
|
298
|
+
const skill = {
|
|
299
|
+
name: 'test-skill',
|
|
300
|
+
description: 'Test skill',
|
|
301
|
+
triggers: ['test'],
|
|
302
|
+
body: `# Heading
|
|
303
|
+
|
|
304
|
+
<!-- transport:claude -->
|
|
305
|
+
Claude-only content.
|
|
306
|
+
<!-- /transport -->
|
|
307
|
+
|
|
308
|
+
<!-- transport:codex -->
|
|
309
|
+
Codex-only content.
|
|
310
|
+
<!-- /transport -->
|
|
311
|
+
|
|
312
|
+
Universal content.`,
|
|
313
|
+
sourcePath: '/tmp/.productbrain/skills/test-skill.md',
|
|
314
|
+
};
|
|
315
|
+
const output = generateCodexSkill(skill);
|
|
316
|
+
expect(output).toContain('Codex-only content.');
|
|
317
|
+
expect(output).not.toContain('Claude-only content.');
|
|
318
|
+
expect(output).toContain('Universal content.');
|
|
319
|
+
});
|
|
320
|
+
it('generates a Codex skill index with links to projected skills', () => {
|
|
321
|
+
const skills = [
|
|
322
|
+
{
|
|
323
|
+
name: 'preflight',
|
|
324
|
+
description: 'Ground the task before changing code.',
|
|
325
|
+
triggers: ['preflight', 'system check'],
|
|
326
|
+
body: '# Preflight',
|
|
327
|
+
sourcePath: '/tmp/.productbrain/skills/preflight.md',
|
|
328
|
+
},
|
|
329
|
+
];
|
|
330
|
+
const output = generateCodexSkillIndex(skills);
|
|
331
|
+
expect(output).toContain('Product Brain Skills for Codex');
|
|
332
|
+
expect(output).toContain('## preflight');
|
|
333
|
+
expect(output).toContain('`preflight`');
|
|
334
|
+
expect(output).toContain('Read: `.codex/skills/preflight.md`');
|
|
335
|
+
});
|
|
336
|
+
it('hides learnings and template files from the Codex skill index', () => {
|
|
337
|
+
const skills = [
|
|
338
|
+
{
|
|
339
|
+
name: 'preflight',
|
|
340
|
+
description: 'Ground the task before changing code.',
|
|
341
|
+
triggers: ['preflight'],
|
|
342
|
+
body: '# Preflight',
|
|
343
|
+
sourcePath: '/tmp/.productbrain/skills/preflight.md',
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: 'retro-learnings',
|
|
347
|
+
description: 'Internal learnings companion.',
|
|
348
|
+
triggers: [],
|
|
349
|
+
body: '# Retro Learnings',
|
|
350
|
+
sourcePath: '/tmp/.productbrain/skills/retro-learnings.md',
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
name: 'LEARNINGS-TEMPLATE',
|
|
354
|
+
description: 'Template',
|
|
355
|
+
triggers: [],
|
|
356
|
+
body: '# Template',
|
|
357
|
+
sourcePath: '/tmp/.productbrain/skills/LEARNINGS-TEMPLATE.md',
|
|
358
|
+
},
|
|
359
|
+
];
|
|
360
|
+
const output = generateCodexSkillIndex(skills);
|
|
361
|
+
expect(output).toContain('## preflight');
|
|
362
|
+
expect(output).not.toContain('## retro-learnings');
|
|
363
|
+
expect(output).not.toContain('## LEARNINGS-TEMPLATE');
|
|
364
|
+
expect(output).toContain('hidden from this index');
|
|
365
|
+
});
|
|
366
|
+
});
|
|
295
367
|
describe('generateClaudeSkillRouter (target filtering)', () => {
|
|
296
368
|
it('includes only skills with matching or no targets', () => {
|
|
297
369
|
const claudeOnly = {
|
|
@@ -355,6 +427,120 @@ describe('generateClaudeSkillRouter (target filtering)', () => {
|
|
|
355
427
|
expect(cursorOutput).toContain('universal');
|
|
356
428
|
});
|
|
357
429
|
});
|
|
430
|
+
describe('generateCursorRule (scope propagation)', () => {
|
|
431
|
+
const baseRule = {
|
|
432
|
+
name: 'test-rule',
|
|
433
|
+
description: 'A test rule',
|
|
434
|
+
autoApply: true,
|
|
435
|
+
body: '# Rule body content',
|
|
436
|
+
sourcePath: '/tmp/.productbrain/rules/test-rule.md',
|
|
437
|
+
};
|
|
438
|
+
it('emits empty globs and alwaysApply: true when scope is empty string', () => {
|
|
439
|
+
const rule = { ...baseRule, scope: '' };
|
|
440
|
+
const output = generateCursorRule(rule);
|
|
441
|
+
expect(output).toContain('globs: ');
|
|
442
|
+
expect(output).toContain('alwaysApply: true');
|
|
443
|
+
expect(output).toContain('# Rule body content');
|
|
444
|
+
});
|
|
445
|
+
it('emits empty globs and alwaysApply: true when scope is undefined', () => {
|
|
446
|
+
const rule = { ...baseRule };
|
|
447
|
+
const output = generateCursorRule(rule);
|
|
448
|
+
expect(output).toContain('globs: ');
|
|
449
|
+
expect(output).toContain('alwaysApply: true');
|
|
450
|
+
});
|
|
451
|
+
it('emits glob pattern and alwaysApply: false when scope is set', () => {
|
|
452
|
+
const rule = { ...baseRule, scope: 'convex/**/*.ts' };
|
|
453
|
+
const output = generateCursorRule(rule);
|
|
454
|
+
expect(output).toContain('globs: convex/**/*.ts');
|
|
455
|
+
expect(output).toContain('alwaysApply: false');
|
|
456
|
+
});
|
|
457
|
+
it('respects autoApply: false with empty scope', () => {
|
|
458
|
+
const rule = { ...baseRule, autoApply: false, scope: '' };
|
|
459
|
+
const output = generateCursorRule(rule);
|
|
460
|
+
expect(output).toContain('alwaysApply: false');
|
|
461
|
+
});
|
|
462
|
+
it('replaces .productbrain paths with .cursor paths in body', () => {
|
|
463
|
+
const rule = {
|
|
464
|
+
...baseRule,
|
|
465
|
+
body: 'Read `.productbrain/rules/foo.md` and `.productbrain/skills/bar.md`.',
|
|
466
|
+
};
|
|
467
|
+
const output = generateCursorRule(rule);
|
|
468
|
+
expect(output).toContain('.cursor/rules/foo.mdc');
|
|
469
|
+
expect(output).toContain('.cursor/skills/bar/SKILL.md');
|
|
470
|
+
expect(output).not.toContain('.productbrain/rules/foo.md');
|
|
471
|
+
});
|
|
472
|
+
it('includes source marker comment', () => {
|
|
473
|
+
const output = generateCursorRule(baseRule);
|
|
474
|
+
expect(output).toContain('source: .productbrain/rules/test-rule.md');
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
describe('generateClaudeRule (scope propagation)', () => {
|
|
478
|
+
const baseRule = {
|
|
479
|
+
name: 'test-rule',
|
|
480
|
+
description: 'A test rule',
|
|
481
|
+
autoApply: true,
|
|
482
|
+
body: '# Rule body content',
|
|
483
|
+
sourcePath: '/tmp/.productbrain/rules/test-rule.md',
|
|
484
|
+
};
|
|
485
|
+
it('omits paths when scope is empty string', () => {
|
|
486
|
+
const rule = { ...baseRule, scope: '' };
|
|
487
|
+
const output = generateClaudeRule(rule);
|
|
488
|
+
expect(output).not.toContain('paths:');
|
|
489
|
+
expect(output).toContain('description: "A test rule"');
|
|
490
|
+
expect(output).toContain('# Rule body content');
|
|
491
|
+
});
|
|
492
|
+
it('omits paths when scope is undefined', () => {
|
|
493
|
+
const rule = { ...baseRule };
|
|
494
|
+
const output = generateClaudeRule(rule);
|
|
495
|
+
expect(output).not.toContain('paths:');
|
|
496
|
+
});
|
|
497
|
+
it('emits paths array when scope is set', () => {
|
|
498
|
+
const rule = { ...baseRule, scope: 'convex/**/*.ts' };
|
|
499
|
+
const output = generateClaudeRule(rule);
|
|
500
|
+
expect(output).toContain('paths:');
|
|
501
|
+
expect(output).toContain(' - "convex/**/*.ts"');
|
|
502
|
+
});
|
|
503
|
+
it('escapes quotes in description', () => {
|
|
504
|
+
const rule = { ...baseRule, description: 'Rule with "quotes" inside' };
|
|
505
|
+
const output = generateClaudeRule(rule);
|
|
506
|
+
expect(output).toContain('description: "Rule with \\"quotes\\" inside"');
|
|
507
|
+
});
|
|
508
|
+
it('replaces .productbrain paths with .claude paths in body', () => {
|
|
509
|
+
const rule = {
|
|
510
|
+
...baseRule,
|
|
511
|
+
body: 'Read `.productbrain/rules/foo.md` for details.',
|
|
512
|
+
};
|
|
513
|
+
const output = generateClaudeRule(rule);
|
|
514
|
+
expect(output).toContain('.claude/rules/foo.md');
|
|
515
|
+
expect(output).not.toContain('.productbrain/rules/foo.md');
|
|
516
|
+
});
|
|
517
|
+
it('includes source marker comment', () => {
|
|
518
|
+
const output = generateClaudeRule(baseRule);
|
|
519
|
+
expect(output).toContain('source: .productbrain/rules/test-rule.md');
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
describe('generateCodexSkillIndex (empty primary skills)', () => {
|
|
523
|
+
it('shows empty message when all skills are learnings/templates', () => {
|
|
524
|
+
const skills = [
|
|
525
|
+
{
|
|
526
|
+
name: 'retro-learnings',
|
|
527
|
+
description: 'Internal learnings companion.',
|
|
528
|
+
triggers: [],
|
|
529
|
+
body: '# Retro Learnings',
|
|
530
|
+
sourcePath: '/tmp/.productbrain/skills/retro-learnings.md',
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
name: 'LEARNINGS-TEMPLATE',
|
|
534
|
+
description: 'Template',
|
|
535
|
+
triggers: [],
|
|
536
|
+
body: '# Template',
|
|
537
|
+
sourcePath: '/tmp/.productbrain/skills/LEARNINGS-TEMPLATE.md',
|
|
538
|
+
},
|
|
539
|
+
];
|
|
540
|
+
const output = generateCodexSkillIndex(skills);
|
|
541
|
+
expect(output).toContain('No Product Brain skills are currently projected');
|
|
542
|
+
});
|
|
543
|
+
});
|
|
358
544
|
describe('filterByLevel', () => {
|
|
359
545
|
const items = [
|
|
360
546
|
{ name: 'core-item', level: 'core' },
|
|
@@ -396,4 +582,346 @@ describe('filterByLevel', () => {
|
|
|
396
582
|
expect(() => filterByLevel(items, 'unknown')).toThrow('Unknown level "unknown"');
|
|
397
583
|
});
|
|
398
584
|
});
|
|
585
|
+
describe('filterByLevel — stage-gating (DEC-443)', () => {
|
|
586
|
+
const items = [
|
|
587
|
+
{ name: 'core-item', level: 'core' },
|
|
588
|
+
{ name: 'intermediate-item', level: 'intermediate' },
|
|
589
|
+
{ name: 'expert-item', level: 'expert' },
|
|
590
|
+
{ name: 'no-level-item' },
|
|
591
|
+
];
|
|
592
|
+
it('stage=blank caps at core even if requestedLevel=expert', () => {
|
|
593
|
+
const result = filterByLevel(items, 'expert', 'blank');
|
|
594
|
+
const names = result.map((i) => i.name);
|
|
595
|
+
expect(names).toContain('core-item');
|
|
596
|
+
expect(names).toContain('no-level-item');
|
|
597
|
+
expect(names).not.toContain('intermediate-item');
|
|
598
|
+
expect(names).not.toContain('expert-item');
|
|
599
|
+
expect(result).toHaveLength(2);
|
|
600
|
+
});
|
|
601
|
+
it('stage=seed caps at intermediate (core + intermediate + no-level)', () => {
|
|
602
|
+
const result = filterByLevel(items, 'expert', 'seed');
|
|
603
|
+
const names = result.map((i) => i.name);
|
|
604
|
+
expect(names).toContain('core-item');
|
|
605
|
+
expect(names).toContain('intermediate-item');
|
|
606
|
+
expect(names).toContain('no-level-item');
|
|
607
|
+
expect(names).not.toContain('expert-item');
|
|
608
|
+
expect(result).toHaveLength(3);
|
|
609
|
+
});
|
|
610
|
+
it('stage=grounded applies no cap — all items returned', () => {
|
|
611
|
+
const result = filterByLevel(items, 'expert', 'grounded');
|
|
612
|
+
expect(result).toHaveLength(4);
|
|
613
|
+
});
|
|
614
|
+
it('stage=connected applies no cap — all items returned', () => {
|
|
615
|
+
const result = filterByLevel(items, 'expert', 'connected');
|
|
616
|
+
expect(result).toHaveLength(4);
|
|
617
|
+
});
|
|
618
|
+
it('stage=undefined (no profile) — no cap, backward compat', () => {
|
|
619
|
+
const result = filterByLevel(items, 'expert', undefined);
|
|
620
|
+
expect(result).toHaveLength(4);
|
|
621
|
+
});
|
|
622
|
+
it('unknown stage — fail-open, no cap applied', () => {
|
|
623
|
+
const result = filterByLevel(items, 'expert', 'unknown-stage');
|
|
624
|
+
expect(result).toHaveLength(4);
|
|
625
|
+
});
|
|
626
|
+
it('no requestedLevel + stage=blank still caps at beginner (core + no-level only)', () => {
|
|
627
|
+
const result = filterByLevel(items, undefined, 'blank');
|
|
628
|
+
const names = result.map((i) => i.name);
|
|
629
|
+
expect(names).toContain('core-item');
|
|
630
|
+
expect(names).toContain('no-level-item');
|
|
631
|
+
expect(names).not.toContain('intermediate-item');
|
|
632
|
+
expect(names).not.toContain('expert-item');
|
|
633
|
+
expect(result).toHaveLength(2);
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
describe('evaluateConditions', () => {
|
|
637
|
+
const noProfile = null;
|
|
638
|
+
const profile = {
|
|
639
|
+
stage: 'grounded',
|
|
640
|
+
totalRelations: 10,
|
|
641
|
+
};
|
|
642
|
+
const emptyRepo = { detectedStack: [] };
|
|
643
|
+
const sveltekitRepo = { detectedStack: ['sveltekit', 'typescript'] };
|
|
644
|
+
// 1. No conditions → included
|
|
645
|
+
it('no conditions → included', () => {
|
|
646
|
+
const result = evaluateConditions({}, profile, sveltekitRepo);
|
|
647
|
+
expect(result.included).toBe(true);
|
|
648
|
+
expect(result.reasons).toEqual(['no conditions']);
|
|
649
|
+
});
|
|
650
|
+
// 2. Matching when_stack → included
|
|
651
|
+
it('matching when_stack → included', () => {
|
|
652
|
+
const result = evaluateConditions({ when_stack: 'sveltekit' }, profile, sveltekitRepo);
|
|
653
|
+
expect(result.included).toBe(true);
|
|
654
|
+
expect(result.reasons.some((r) => r.includes('matched'))).toBe(true);
|
|
655
|
+
});
|
|
656
|
+
// 3. Non-matching when_stack → excluded with reason
|
|
657
|
+
it('non-matching when_stack → excluded with reason', () => {
|
|
658
|
+
const result = evaluateConditions({ when_stack: 'nextjs' }, profile, sveltekitRepo);
|
|
659
|
+
expect(result.included).toBe(false);
|
|
660
|
+
expect(result.reasons.some((r) => r.includes('when_stack=nextjs'))).toBe(true);
|
|
661
|
+
});
|
|
662
|
+
// 4. when_stack case-insensitive matching
|
|
663
|
+
it('when_stack is case-insensitive', () => {
|
|
664
|
+
const result = evaluateConditions({ when_stack: 'SvelteKit' }, profile, { detectedStack: ['sveltekit'] });
|
|
665
|
+
expect(result.included).toBe(true);
|
|
666
|
+
});
|
|
667
|
+
// 5. when_minStage ordering: blank < seed < grounded < connected < critical
|
|
668
|
+
it('when_minStage ordering: stage below threshold → excluded', () => {
|
|
669
|
+
const seedProfile = { stage: 'seed', totalRelations: 10 };
|
|
670
|
+
const result = evaluateConditions({ when_minStage: 'grounded' }, seedProfile, emptyRepo);
|
|
671
|
+
expect(result.included).toBe(false);
|
|
672
|
+
expect(result.reasons.some((r) => r.includes('when_minStage=grounded'))).toBe(true);
|
|
673
|
+
});
|
|
674
|
+
// 6. when_minStage with stage below threshold → excluded
|
|
675
|
+
it('when_minStage blank < connected threshold → excluded', () => {
|
|
676
|
+
const blankProfile = { stage: 'blank', totalRelations: 0 };
|
|
677
|
+
const result = evaluateConditions({ when_minStage: 'connected' }, blankProfile, emptyRepo);
|
|
678
|
+
expect(result.included).toBe(false);
|
|
679
|
+
});
|
|
680
|
+
// 7. when_minStage with stage at threshold → included
|
|
681
|
+
it('when_minStage with stage exactly at threshold → included', () => {
|
|
682
|
+
const result = evaluateConditions({ when_minStage: 'grounded' }, profile, emptyRepo);
|
|
683
|
+
expect(result.included).toBe(true);
|
|
684
|
+
expect(result.reasons.some((r) => r.includes('satisfied'))).toBe(true);
|
|
685
|
+
});
|
|
686
|
+
// Also verify stages above threshold pass
|
|
687
|
+
it('when_minStage with stage above threshold → included', () => {
|
|
688
|
+
const criticalProfile = { stage: 'critical', totalRelations: 10 };
|
|
689
|
+
const result = evaluateConditions({ when_minStage: 'grounded' }, criticalProfile, emptyRepo);
|
|
690
|
+
expect(result.included).toBe(true);
|
|
691
|
+
});
|
|
692
|
+
// 8. when_minGovernance with enough relations → included
|
|
693
|
+
it('when_minGovernance with enough relations → included', () => {
|
|
694
|
+
const result = evaluateConditions({ when_minGovernance: '5' }, profile, emptyRepo);
|
|
695
|
+
expect(result.included).toBe(true);
|
|
696
|
+
expect(result.reasons.some((r) => r.includes('satisfied'))).toBe(true);
|
|
697
|
+
});
|
|
698
|
+
// 9. when_minGovernance with too few → excluded
|
|
699
|
+
it('when_minGovernance with too few relations → excluded', () => {
|
|
700
|
+
const lowRelProfile = { stage: 'grounded', totalRelations: 3 };
|
|
701
|
+
const result = evaluateConditions({ when_minGovernance: '5' }, lowRelProfile, emptyRepo);
|
|
702
|
+
expect(result.included).toBe(false);
|
|
703
|
+
expect(result.reasons.some((r) => r.includes('when_minGovernance=5'))).toBe(true);
|
|
704
|
+
});
|
|
705
|
+
// 10. Multiple conditions: all pass → included
|
|
706
|
+
it('multiple conditions: all pass → included', () => {
|
|
707
|
+
const result = evaluateConditions({ when_stack: 'sveltekit', when_minStage: 'seed', when_minGovernance: '5' }, profile, sveltekitRepo);
|
|
708
|
+
expect(result.included).toBe(true);
|
|
709
|
+
});
|
|
710
|
+
// 11. Multiple conditions: one fails → excluded (AND semantics)
|
|
711
|
+
it('multiple conditions: one fails → excluded', () => {
|
|
712
|
+
const result = evaluateConditions({ when_stack: 'sveltekit', when_minStage: 'critical' }, profile, // stage=grounded, below critical
|
|
713
|
+
sveltekitRepo);
|
|
714
|
+
expect(result.included).toBe(false);
|
|
715
|
+
expect(result.reasons.some((r) => r.includes('when_minStage=critical'))).toBe(true);
|
|
716
|
+
});
|
|
717
|
+
// 12. Null profile + when_minStage → included (fail-open)
|
|
718
|
+
it('null profile with when_minStage → fail-open (included)', () => {
|
|
719
|
+
const result = evaluateConditions({ when_minStage: 'grounded' }, noProfile, emptyRepo);
|
|
720
|
+
expect(result.included).toBe(true);
|
|
721
|
+
expect(result.reasons.some((r) => r.includes('fail-open'))).toBe(true);
|
|
722
|
+
});
|
|
723
|
+
// 13. Null profile + when_stack only → still evaluated (stack comes from repoContext)
|
|
724
|
+
it('null profile with when_stack only → still evaluated from repoContext', () => {
|
|
725
|
+
const matchResult = evaluateConditions({ when_stack: 'sveltekit' }, noProfile, sveltekitRepo);
|
|
726
|
+
expect(matchResult.included).toBe(true);
|
|
727
|
+
const noMatchResult = evaluateConditions({ when_stack: 'nextjs' }, noProfile, sveltekitRepo);
|
|
728
|
+
expect(noMatchResult.included).toBe(false);
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
describe('BET-170 acceptance criteria — integration', () => {
|
|
732
|
+
/**
|
|
733
|
+
* Mock rules cover all combinations: stack condition, stage condition,
|
|
734
|
+
* governance condition, and unconditional at core/intermediate/expert levels.
|
|
735
|
+
*/
|
|
736
|
+
const mockRules = [
|
|
737
|
+
{ name: 'deployment', level: 'core', conditions: { when_stack: 'sveltekit' } },
|
|
738
|
+
{ name: 'feature-flags', level: 'intermediate', conditions: { when_minStage: 'grounded' } },
|
|
739
|
+
{ name: 'domain-boundaries', level: 'expert', conditions: { when_minGovernance: '5' } },
|
|
740
|
+
{ name: 'code-integrity', level: 'core' }, // no conditions — always included
|
|
741
|
+
{ name: 'review-gate', level: 'intermediate' }, // no conditions
|
|
742
|
+
{ name: 'orchestrator-mode', level: 'expert' }, // no conditions
|
|
743
|
+
];
|
|
744
|
+
function applyPipeline(rules, profile, repo) {
|
|
745
|
+
const profileStage = profile?.stage;
|
|
746
|
+
// stage-gated level filter: no requestedLevel (defaults to widest), stage caps it
|
|
747
|
+
const levelFiltered = filterByLevel(rules, undefined, profileStage);
|
|
748
|
+
// condition filter
|
|
749
|
+
return levelFiltered.filter((rule) => {
|
|
750
|
+
const result = evaluateConditions(rule.conditions ?? {}, profile, repo);
|
|
751
|
+
return result.included;
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
// AC1: Fresh Python workspace (stage=blank, 0 entries) — only core items with no conditions
|
|
755
|
+
it('AC1: stage=blank, python — only core unconditional items', () => {
|
|
756
|
+
const profile = { stage: 'blank', totalEntries: 0, totalRelations: 0 };
|
|
757
|
+
const repo = { detectedStack: ['python'] };
|
|
758
|
+
const result = applyPipeline(mockRules, profile, repo);
|
|
759
|
+
const names = result.map((r) => r.name);
|
|
760
|
+
// Only code-integrity: core level, no conditions
|
|
761
|
+
// deployment: core but when_stack=sveltekit fails (python != sveltekit)
|
|
762
|
+
expect(names).toContain('code-integrity');
|
|
763
|
+
expect(names).not.toContain('deployment'); // core but sveltekit condition fails
|
|
764
|
+
expect(names).not.toContain('feature-flags'); // intermediate — capped by blank stage
|
|
765
|
+
expect(names).not.toContain('domain-boundaries'); // expert — capped by blank stage
|
|
766
|
+
expect(names).not.toContain('review-gate'); // intermediate — capped by blank stage
|
|
767
|
+
expect(names).not.toContain('orchestrator-mode'); // expert — capped by blank stage
|
|
768
|
+
expect(result).toHaveLength(1);
|
|
769
|
+
});
|
|
770
|
+
// AC2: Node/TS workspace (stage=grounded, 30 entries) — stack-matched, all levels
|
|
771
|
+
it('AC2: stage=grounded, sveltekit/typescript, 30 entries — all rules included', () => {
|
|
772
|
+
const profile = { stage: 'grounded', totalEntries: 30, totalRelations: 15 };
|
|
773
|
+
const repo = { detectedStack: ['typescript', 'sveltekit'] };
|
|
774
|
+
const result = applyPipeline(mockRules, profile, repo);
|
|
775
|
+
const names = result.map((r) => r.name);
|
|
776
|
+
// All conditions pass: sveltekit matches, grounded >= grounded, 15 >= 5
|
|
777
|
+
expect(names).toContain('deployment');
|
|
778
|
+
expect(names).toContain('feature-flags');
|
|
779
|
+
expect(names).toContain('domain-boundaries');
|
|
780
|
+
expect(names).toContain('code-integrity');
|
|
781
|
+
expect(names).toContain('review-gate');
|
|
782
|
+
expect(names).toContain('orchestrator-mode');
|
|
783
|
+
expect(result).toHaveLength(6);
|
|
784
|
+
});
|
|
785
|
+
// AC3: Mature workspace (stage=connected, 100+ entries) — all rules and skills
|
|
786
|
+
it('AC3: stage=connected, 150 entries — all rules projected without filtering', () => {
|
|
787
|
+
const profile = { stage: 'connected', totalEntries: 150, totalRelations: 80 };
|
|
788
|
+
const repo = { detectedStack: ['typescript', 'sveltekit'] };
|
|
789
|
+
const result = applyPipeline(mockRules, profile, repo);
|
|
790
|
+
expect(result).toHaveLength(6);
|
|
791
|
+
});
|
|
792
|
+
// AC4: Unknown stack — general template set, no stack-specific rules
|
|
793
|
+
it('AC4: stage=grounded, unknown stack — only non-stack-conditional rules', () => {
|
|
794
|
+
const profile = { stage: 'grounded', totalEntries: 30, totalRelations: 15 };
|
|
795
|
+
const repo = { detectedStack: [] };
|
|
796
|
+
const result = applyPipeline(mockRules, profile, repo);
|
|
797
|
+
const names = result.map((r) => r.name);
|
|
798
|
+
// deployment requires sveltekit stack — excluded
|
|
799
|
+
// all others: feature-flags (grounded ok), domain-boundaries (15 >= 5), unconditionals all pass
|
|
800
|
+
expect(names).not.toContain('deployment');
|
|
801
|
+
expect(names).toContain('feature-flags');
|
|
802
|
+
expect(names).toContain('domain-boundaries');
|
|
803
|
+
expect(names).toContain('code-integrity');
|
|
804
|
+
expect(names).toContain('review-gate');
|
|
805
|
+
expect(names).toContain('orchestrator-mode');
|
|
806
|
+
expect(result).toHaveLength(5);
|
|
807
|
+
});
|
|
808
|
+
// AC5: workspaceReadiness failure (null profile) — fail-open, all rules/skills project
|
|
809
|
+
it('AC5: null profile (readiness failure) — fail-open, profile conditions pass', () => {
|
|
810
|
+
const profile = null;
|
|
811
|
+
const repo = { detectedStack: ['typescript'] };
|
|
812
|
+
const result = applyPipeline(mockRules, profile, repo);
|
|
813
|
+
const names = result.map((r) => r.name);
|
|
814
|
+
// Null profile:
|
|
815
|
+
// - filterByLevel: no stage cap → all levels pass
|
|
816
|
+
// - evaluateConditions: when_minStage + when_minGovernance → fail-open (included)
|
|
817
|
+
// - when_stack: still evaluated against repoContext; sveltekit not in [typescript] → excluded
|
|
818
|
+
expect(names).not.toContain('deployment'); // when_stack=sveltekit, typescript doesn't match
|
|
819
|
+
expect(names).toContain('feature-flags'); // fail-open (profile null)
|
|
820
|
+
expect(names).toContain('domain-boundaries'); // fail-open (profile null)
|
|
821
|
+
expect(names).toContain('code-integrity');
|
|
822
|
+
expect(names).toContain('review-gate');
|
|
823
|
+
expect(names).toContain('orchestrator-mode');
|
|
824
|
+
expect(result).toHaveLength(5);
|
|
825
|
+
});
|
|
826
|
+
// Scale test: 0/5/50/500 entries — filtering correctness at different entry counts
|
|
827
|
+
it('scale test: filtering correctness at 0/5/50/500 totalEntries', () => {
|
|
828
|
+
const repo = { detectedStack: ['sveltekit'] };
|
|
829
|
+
// 0 entries: blank stage, low relations
|
|
830
|
+
// deployment: core level (passes stage cap), sveltekit matches → included
|
|
831
|
+
// code-integrity: core level, no conditions → included
|
|
832
|
+
// everything else: capped by blank stage (intermediate/expert excluded)
|
|
833
|
+
const result0 = applyPipeline(mockRules, { stage: 'blank', totalEntries: 0, totalRelations: 0 }, repo);
|
|
834
|
+
const names0 = result0.map((r) => r.name);
|
|
835
|
+
expect(names0).toContain('deployment');
|
|
836
|
+
expect(names0).toContain('code-integrity');
|
|
837
|
+
expect(names0).not.toContain('feature-flags'); // intermediate — capped
|
|
838
|
+
expect(names0).not.toContain('domain-boundaries'); // expert — capped
|
|
839
|
+
expect(names0).not.toContain('review-gate'); // intermediate — capped
|
|
840
|
+
expect(names0).not.toContain('orchestrator-mode'); // expert — capped
|
|
841
|
+
expect(result0).toHaveLength(2);
|
|
842
|
+
// 5 entries: seed stage, 2 relations (below governance threshold)
|
|
843
|
+
const result5 = applyPipeline(mockRules, { stage: 'seed', totalEntries: 5, totalRelations: 2 }, repo);
|
|
844
|
+
const names5 = result5.map((r) => r.name);
|
|
845
|
+
// seed caps at intermediate; domain-boundaries (expert) + orchestrator-mode (expert) excluded
|
|
846
|
+
// domain-boundaries also fails governance (2 < 5)
|
|
847
|
+
// feature-flags: intermediate ok, but when_minStage=grounded fails for seed → excluded
|
|
848
|
+
// deployment: core ok, sveltekit matches → included
|
|
849
|
+
expect(names5).toContain('deployment');
|
|
850
|
+
expect(names5).toContain('code-integrity');
|
|
851
|
+
expect(names5).toContain('review-gate');
|
|
852
|
+
expect(names5).not.toContain('domain-boundaries');
|
|
853
|
+
expect(names5).not.toContain('orchestrator-mode');
|
|
854
|
+
expect(names5).not.toContain('feature-flags');
|
|
855
|
+
// 50 entries: grounded stage, 20 relations
|
|
856
|
+
const result50 = applyPipeline(mockRules, { stage: 'grounded', totalEntries: 50, totalRelations: 20 }, repo);
|
|
857
|
+
expect(result50).toHaveLength(6);
|
|
858
|
+
// 500 entries: connected stage, 200 relations
|
|
859
|
+
const result500 = applyPipeline(mockRules, { stage: 'connected', totalEntries: 500, totalRelations: 200 }, repo);
|
|
860
|
+
expect(result500).toHaveLength(6);
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
describe('readCanonicalRules — backward compat with when_* frontmatter', () => {
|
|
864
|
+
beforeEach(() => {
|
|
865
|
+
Object.keys(vfs).forEach((k) => delete vfs[k]);
|
|
866
|
+
});
|
|
867
|
+
it('parses when_stack from frontmatter into conditions', () => {
|
|
868
|
+
vfs[join(PB_DIR, 'rules', 'deploy.md')] = `---
|
|
869
|
+
name: deploy
|
|
870
|
+
description: Deploy rule
|
|
871
|
+
scope: ""
|
|
872
|
+
autoApply: true
|
|
873
|
+
when_stack: sveltekit
|
|
874
|
+
---
|
|
875
|
+
|
|
876
|
+
# Deploy body
|
|
877
|
+
`;
|
|
878
|
+
const rules = readCanonicalRules(PB_DIR);
|
|
879
|
+
expect(rules).toHaveLength(1);
|
|
880
|
+
expect(rules[0].conditions?.when_stack).toBe('sveltekit');
|
|
881
|
+
});
|
|
882
|
+
it('parses when_minStage from frontmatter into conditions', () => {
|
|
883
|
+
vfs[join(PB_DIR, 'rules', 'flags.md')] = `---
|
|
884
|
+
name: flags
|
|
885
|
+
description: Feature flags rule
|
|
886
|
+
scope: ""
|
|
887
|
+
autoApply: false
|
|
888
|
+
when_minStage: grounded
|
|
889
|
+
---
|
|
890
|
+
|
|
891
|
+
# Flags body
|
|
892
|
+
`;
|
|
893
|
+
const rules = readCanonicalRules(PB_DIR);
|
|
894
|
+
expect(rules).toHaveLength(1);
|
|
895
|
+
expect(rules[0].conditions?.when_minStage).toBe('grounded');
|
|
896
|
+
});
|
|
897
|
+
it('parses when_minGovernance from frontmatter into conditions', () => {
|
|
898
|
+
vfs[join(PB_DIR, 'rules', 'domains.md')] = `---
|
|
899
|
+
name: domains
|
|
900
|
+
description: Domain boundaries rule
|
|
901
|
+
scope: ""
|
|
902
|
+
autoApply: true
|
|
903
|
+
when_minGovernance: 5
|
|
904
|
+
---
|
|
905
|
+
|
|
906
|
+
# Domains body
|
|
907
|
+
`;
|
|
908
|
+
const rules = readCanonicalRules(PB_DIR);
|
|
909
|
+
expect(rules).toHaveLength(1);
|
|
910
|
+
expect(rules[0].conditions?.when_minGovernance).toBe('5');
|
|
911
|
+
});
|
|
912
|
+
it('returns undefined conditions when no when_* keys are present (backward compat)', () => {
|
|
913
|
+
vfs[join(PB_DIR, 'rules', 'plain.md')] = `---
|
|
914
|
+
name: plain
|
|
915
|
+
description: Plain rule
|
|
916
|
+
scope: ""
|
|
917
|
+
autoApply: true
|
|
918
|
+
---
|
|
919
|
+
|
|
920
|
+
# Plain body
|
|
921
|
+
`;
|
|
922
|
+
const rules = readCanonicalRules(PB_DIR);
|
|
923
|
+
expect(rules).toHaveLength(1);
|
|
924
|
+
expect(rules[0].conditions).toBeUndefined();
|
|
925
|
+
});
|
|
926
|
+
});
|
|
399
927
|
//# sourceMappingURL=portable-knowledge.test.js.map
|