@rune-kit/rune 2.13.0 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -12
- package/compiler/__tests__/adr-scoring.test.js +91 -0
- package/compiler/__tests__/context-md-format.test.js +180 -0
- package/compiler/__tests__/context-pack-smell-tests.test.js +215 -0
- package/compiler/__tests__/diversity-score.test.js +117 -0
- package/compiler/__tests__/improve-architecture.test.js +171 -0
- package/compiler/__tests__/openclaw-adapter.test.js +11 -6
- package/compiler/__tests__/out-of-scope-format.test.js +303 -0
- package/compiler/__tests__/zoom-out-output.test.js +112 -0
- package/package.json +2 -2
- package/skills/audit/SKILL.md +1 -0
- package/skills/ba/SKILL.md +241 -82
- package/skills/ba/references/context-md-format.md +136 -0
- package/skills/ba/references/explore-first.md +107 -0
- package/skills/ba/references/out-of-scope-format.md +152 -0
- package/skills/brainstorm/SKILL.md +77 -1
- package/skills/brainstorm/references/design-it-twice.md +155 -0
- package/skills/context-pack/SKILL.md +68 -25
- package/skills/context-pack/evals.md +122 -0
- package/skills/context-pack/references/brief-template.md +121 -0
- package/skills/context-pack/references/durability-rules.md +109 -0
- package/skills/cook/SKILL.md +4 -4
- package/skills/debug/SKILL.md +2 -1
- package/skills/fix/SKILL.md +2 -1
- package/skills/improve-architecture/SKILL.md +275 -0
- package/skills/improve-architecture/evals.md +145 -0
- package/skills/improve-architecture/references/deepening.md +87 -0
- package/skills/improve-architecture/references/interface-design.md +68 -0
- package/skills/improve-architecture/references/language.md +81 -0
- package/skills/improve-architecture/references/scoring.md +122 -0
- package/skills/journal/SKILL.md +33 -6
- package/skills/journal/references/adr-criteria.md +109 -0
- package/skills/review/SKILL.md +1 -0
- package/skills/review-intake/SKILL.md +25 -2
- package/skills/scout/SKILL.md +33 -1
- package/skills/surgeon/SKILL.md +16 -1
- package/skills/test/SKILL.md +48 -1
- package/skills/test/evals.md +137 -0
- package/skills/test/references/mocking-policy.md +121 -0
- package/skills/test/references/test-quality.md +124 -0
- package/skills/test/references/vertical-tdd.md +110 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diversity score for Design-It-Twice mode.
|
|
3
|
+
*
|
|
4
|
+
* Computes pairwise Jaccard similarity over feature vectors of N designs.
|
|
5
|
+
* Returns 1 - mean(similarity).
|
|
6
|
+
*
|
|
7
|
+
* Used by brainstorm v0.6 Step 3.5 to gate re-spawning when designs are too similar.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from 'node:assert';
|
|
11
|
+
import { describe, test } from 'node:test';
|
|
12
|
+
|
|
13
|
+
function jaccard(a, b) {
|
|
14
|
+
const setA = new Set(a);
|
|
15
|
+
const setB = new Set(b);
|
|
16
|
+
const intersect = new Set([...setA].filter((x) => setB.has(x)));
|
|
17
|
+
const union = new Set([...setA, ...setB]);
|
|
18
|
+
if (union.size === 0) return 1;
|
|
19
|
+
return intersect.size / union.size;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function diversityScore(designs) {
|
|
23
|
+
if (designs.length < 2) return 1;
|
|
24
|
+
const pairs = [];
|
|
25
|
+
for (let i = 0; i < designs.length; i++) {
|
|
26
|
+
for (let j = i + 1; j < designs.length; j++) {
|
|
27
|
+
pairs.push(jaccard(designs[i], designs[j]));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const mean = pairs.reduce((a, b) => a + b, 0) / pairs.length;
|
|
31
|
+
return 1 - mean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Encoding: a feature vector is a list of feature tokens.
|
|
35
|
+
// Examples:
|
|
36
|
+
// ["methods:1", "returns:1", "adapters:0", "deps:1", "paradigm:minimal", "async:no", "stream:no"]
|
|
37
|
+
// ["methods:8", "returns:5", "adapters:3", "deps:5", "paradigm:extensible", "async:yes", "stream:yes"]
|
|
38
|
+
|
|
39
|
+
describe('diversity score', () => {
|
|
40
|
+
test('identical designs return diversity 0', () => {
|
|
41
|
+
const d1 = ['methods:3', 'paradigm:minimal'];
|
|
42
|
+
const d2 = ['methods:3', 'paradigm:minimal'];
|
|
43
|
+
assert.strictEqual(diversityScore([d1, d2]), 0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('disjoint designs return diversity 1', () => {
|
|
47
|
+
const d1 = ['methods:3', 'paradigm:minimal'];
|
|
48
|
+
const d2 = ['methods:8', 'paradigm:extensible'];
|
|
49
|
+
assert.strictEqual(diversityScore([d1, d2]), 1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('half-overlap returns diversity 0.5', () => {
|
|
53
|
+
const d1 = ['methods:3', 'paradigm:minimal'];
|
|
54
|
+
const d2 = ['methods:3', 'paradigm:extensible'];
|
|
55
|
+
// jaccard = 1/3 (intersection {methods:3}, union {methods:3, paradigm:minimal, paradigm:extensible})
|
|
56
|
+
// diversity = 1 - 1/3 = 0.6667
|
|
57
|
+
const diversity = diversityScore([d1, d2]);
|
|
58
|
+
assert.ok(Math.abs(diversity - 0.6667) < 0.001, `expected ~0.6667, got ${diversity}`);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('three radically different designs pass 0.6 floor', () => {
|
|
62
|
+
const c1 = ['methods:2', 'returns:1', 'paradigm:minimal', 'adapters:0', 'async:no'];
|
|
63
|
+
const c2 = ['methods:8', 'returns:5', 'paradigm:extensible', 'adapters:0', 'async:yes'];
|
|
64
|
+
const c4 = ['methods:3', 'returns:2', 'paradigm:ports-adapters', 'adapters:2', 'async:no'];
|
|
65
|
+
const diversity = diversityScore([c1, c2, c4]);
|
|
66
|
+
assert.ok(diversity >= 0.6, `expected >=0.6, got ${diversity}`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('three near-identical designs fail 0.4 floor', () => {
|
|
70
|
+
const a = ['methods:3', 'returns:1', 'paradigm:minimal'];
|
|
71
|
+
const b = ['methods:3', 'returns:1', 'paradigm:minimal'];
|
|
72
|
+
const c = ['methods:3', 'returns:1', 'paradigm:minimal'];
|
|
73
|
+
const diversity = diversityScore([a, b, c]);
|
|
74
|
+
assert.ok(diversity < 0.4, `expected <0.4, got ${diversity}`);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('marginal designs land in 0.4-0.59 zone (warn tier)', () => {
|
|
78
|
+
// 6/9 features shared between each pair → jaccard ≈ 0.50, diversity ≈ 0.50
|
|
79
|
+
const a = ['m:3', 'r:1', 'p:m', 'a:0', 'q:x', 'e:y'];
|
|
80
|
+
const b = ['m:3', 'r:1', 'p:m', 'a:0', 'q:x', 'e:z'];
|
|
81
|
+
const c = ['m:3', 'r:1', 'p:m', 'a:0', 'q:y', 'e:z'];
|
|
82
|
+
const diversity = diversityScore([a, b, c]);
|
|
83
|
+
assert.ok(diversity >= 0.3 && diversity < 0.6, `expected 0.3-0.59, got ${diversity}`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('single design returns diversity 1 (degenerate)', () => {
|
|
87
|
+
assert.strictEqual(diversityScore([['x']]), 1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('four designs (when C4 included) — diverse set passes', () => {
|
|
91
|
+
const c1 = ['methods:2', 'paradigm:minimal'];
|
|
92
|
+
const c2 = ['methods:8', 'paradigm:extensible'];
|
|
93
|
+
const c3 = ['methods:1', 'paradigm:default-light'];
|
|
94
|
+
const c4 = ['methods:3', 'paradigm:ports-adapters'];
|
|
95
|
+
const diversity = diversityScore([c1, c2, c3, c4]);
|
|
96
|
+
assert.ok(diversity >= 0.6, `expected >=0.6, got ${diversity}`);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('threshold gate', () => {
|
|
101
|
+
test('floor 0.4 — below triggers re-spawn', () => {
|
|
102
|
+
const FLOOR = 0.4;
|
|
103
|
+
const lowDiversity = 0.3;
|
|
104
|
+
assert.strictEqual(lowDiversity < FLOOR, true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('threshold 0.6 — proceed without warning', () => {
|
|
108
|
+
const THRESHOLD = 0.6;
|
|
109
|
+
const goodDiversity = 0.7;
|
|
110
|
+
assert.strictEqual(goodDiversity >= THRESHOLD, true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('warn zone 0.4-0.59 — surface to user', () => {
|
|
114
|
+
const value = 0.5;
|
|
115
|
+
assert.ok(value >= 0.4 && value < 0.6);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* improve-architecture skill — structural and vocabulary tests.
|
|
3
|
+
*
|
|
4
|
+
* Validates:
|
|
5
|
+
* - SKILL.md parses, version 0.1.0, layer L2, model opus
|
|
6
|
+
* - Required reference files exist (language, deepening, interface-design, scoring)
|
|
7
|
+
* - evals.md exists with >=4 evals across categories
|
|
8
|
+
* - Vocabulary discipline: skill body + references do NOT use banned aliases
|
|
9
|
+
* - Proposal payload schema shape (sample YAML in SKILL.md parses)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from 'node:assert';
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { describe, test } from 'node:test';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { parseSkill } from '../parser.js';
|
|
18
|
+
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const SKILL_DIR = path.resolve(__dirname, '../../skills/improve-architecture');
|
|
21
|
+
|
|
22
|
+
const REQUIRED_FILES = [
|
|
23
|
+
'SKILL.md',
|
|
24
|
+
'references/language.md',
|
|
25
|
+
'references/deepening.md',
|
|
26
|
+
'references/interface-design.md',
|
|
27
|
+
'references/scoring.md',
|
|
28
|
+
'evals.md',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// --- Files exist ---
|
|
32
|
+
|
|
33
|
+
describe('improve-architecture file presence', () => {
|
|
34
|
+
for (const f of REQUIRED_FILES) {
|
|
35
|
+
test(`${f} exists`, () => {
|
|
36
|
+
const p = path.join(SKILL_DIR, f);
|
|
37
|
+
assert.ok(existsSync(p), `missing: ${f}`);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// --- SKILL.md parses correctly ---
|
|
43
|
+
|
|
44
|
+
test('SKILL.md frontmatter — name, version, layer, model', () => {
|
|
45
|
+
const skillPath = path.join(SKILL_DIR, 'SKILL.md');
|
|
46
|
+
const parsed = parseSkill(readFileSync(skillPath, 'utf-8'));
|
|
47
|
+
assert.strictEqual(parsed.frontmatter.name, 'improve-architecture');
|
|
48
|
+
assert.strictEqual(parsed.frontmatter.metadata.version, '0.1.0');
|
|
49
|
+
assert.strictEqual(parsed.frontmatter.metadata.layer, 'L2');
|
|
50
|
+
assert.strictEqual(parsed.frontmatter.metadata.model, 'opus');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('SKILL.md emit includes architecture.shallow.flagged + architecture.deletion.passed', () => {
|
|
54
|
+
const skillPath = path.join(SKILL_DIR, 'SKILL.md');
|
|
55
|
+
const content = readFileSync(skillPath, 'utf-8');
|
|
56
|
+
assert.ok(/emit:.*architecture\.shallow\.flagged/.test(content), 'emits architecture.shallow.flagged');
|
|
57
|
+
assert.ok(/emit:.*architecture\.deletion\.passed/.test(content), 'emits architecture.deletion.passed');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// --- Required reference content ---
|
|
61
|
+
|
|
62
|
+
test('language.md defines all 8 controlled terms', () => {
|
|
63
|
+
const content = readFileSync(path.join(SKILL_DIR, 'references/language.md'), 'utf-8');
|
|
64
|
+
const required = ['Module', 'Interface', 'Implementation', 'Depth', 'Seam', 'Adapter', 'Leverage', 'Locality'];
|
|
65
|
+
for (const term of required) {
|
|
66
|
+
// Each term should appear as a heading line `### Term`
|
|
67
|
+
assert.ok(new RegExp(`^### ${term}\\b`, 'm').test(content), `language.md missing heading for "${term}"`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('deepening.md lists all 4 dependency categories', () => {
|
|
72
|
+
const content = readFileSync(path.join(SKILL_DIR, 'references/deepening.md'), 'utf-8');
|
|
73
|
+
for (const cat of ['In-process', 'Local-substitutable', 'Remote-owned', 'True-external']) {
|
|
74
|
+
assert.ok(content.includes(cat), `deepening.md missing dependency category "${cat}"`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('scoring.md provides numeric rubric for depth/leverage/locality', () => {
|
|
79
|
+
const content = readFileSync(path.join(SKILL_DIR, 'references/scoring.md'), 'utf-8');
|
|
80
|
+
for (const metric of ['## Depth (1–5)', '## Leverage (1–5)', '## Locality (1–5)']) {
|
|
81
|
+
assert.ok(content.includes(metric), `scoring.md missing section "${metric}"`);
|
|
82
|
+
}
|
|
83
|
+
// Deletion test verdict enum
|
|
84
|
+
for (const verdict of ['vanish', 'concentrate', 'redistribute']) {
|
|
85
|
+
assert.ok(content.includes(verdict), `scoring.md missing deletion-test verdict "${verdict}"`);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// --- Eval coverage ---
|
|
90
|
+
|
|
91
|
+
test('evals.md has >=4 evals across categories', () => {
|
|
92
|
+
const content = readFileSync(path.join(SKILL_DIR, 'evals.md'), 'utf-8');
|
|
93
|
+
const evalMatches = content.match(/^## Eval: E\d+/gm) || [];
|
|
94
|
+
assert.ok(evalMatches.length >= 4, `expected >=4 evals, found ${evalMatches.length}`);
|
|
95
|
+
|
|
96
|
+
for (const cat of ['happy-path', 'edge-case', 'adversarial', 'jailbreak']) {
|
|
97
|
+
assert.ok(content.includes(cat), `evals.md missing category "${cat}"`);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// --- Vocabulary discipline: banned aliases in narrative prose ---
|
|
102
|
+
|
|
103
|
+
describe('vocabulary discipline', () => {
|
|
104
|
+
// Skip narrative scan inside reference files that explicitly enumerate banned terms
|
|
105
|
+
// (language.md lists "Avoid:" rules — those mentions are intentional)
|
|
106
|
+
const SKIPPED_FILES = ['references/language.md'];
|
|
107
|
+
|
|
108
|
+
// Banned alias terms in skill narrative.
|
|
109
|
+
// We allow them inside fenced code blocks AND inside lines starting with "Avoid:" or "Banned"
|
|
110
|
+
// to permit legitimate doctrine prose ("don't say boundary").
|
|
111
|
+
const BANNED_ALIASES = ['boundary', 'component', 'service'];
|
|
112
|
+
|
|
113
|
+
for (const f of REQUIRED_FILES) {
|
|
114
|
+
if (SKIPPED_FILES.includes(f) || f === 'evals.md') continue;
|
|
115
|
+
test(`${f} narrative has no banned aliases`, () => {
|
|
116
|
+
const content = readFileSync(path.join(SKILL_DIR, f), 'utf-8');
|
|
117
|
+
const lines = content.split('\n');
|
|
118
|
+
let inFence = false;
|
|
119
|
+
const violations = [];
|
|
120
|
+
for (let i = 0; i < lines.length; i++) {
|
|
121
|
+
const line = lines[i];
|
|
122
|
+
if (line.trim().startsWith('```')) {
|
|
123
|
+
inFence = !inFence;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (inFence) continue;
|
|
127
|
+
// Skip lines that explicitly call out banned terms
|
|
128
|
+
const trimmed = line.trim();
|
|
129
|
+
if (
|
|
130
|
+
trimmed.startsWith('*Avoid*') ||
|
|
131
|
+
trimmed.startsWith('Avoid:') ||
|
|
132
|
+
trimmed.startsWith('Banned') ||
|
|
133
|
+
trimmed.startsWith('- **') || // banned-framings list items
|
|
134
|
+
/["“”'].*("|”|')\s*$/.test(trimmed) || // quoted phrases like "service"
|
|
135
|
+
line.includes('banned ') // doctrine sentences mentioning banned X
|
|
136
|
+
) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
for (const bad of BANNED_ALIASES) {
|
|
140
|
+
// Match whole word, not substring (so "microservice" wouldn't trigger "service" only when it's a standalone word)
|
|
141
|
+
if (new RegExp(`\\b${bad}\\b`, 'i').test(line) && !line.includes(`"${bad}"`)) {
|
|
142
|
+
violations.push(`${f}:${i + 1}: ${line.trim()}`);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
assert.deepStrictEqual(violations, [], `vocabulary violations in ${f}:\n${violations.join('\n')}`);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// --- Proposal payload sample is parseable ---
|
|
153
|
+
|
|
154
|
+
test('SKILL.md sample proposal payload is well-formed YAML-ish', () => {
|
|
155
|
+
const content = readFileSync(path.join(SKILL_DIR, 'SKILL.md'), 'utf-8');
|
|
156
|
+
// Find the architecture.proposal block
|
|
157
|
+
const match = content.match(/architecture\.proposal:[\s\S]*?(?=\n```|\n\n##)/);
|
|
158
|
+
assert.ok(match, 'SKILL.md missing architecture.proposal sample');
|
|
159
|
+
const block = match[0];
|
|
160
|
+
// Must include required fields
|
|
161
|
+
for (const field of [
|
|
162
|
+
'module_path:',
|
|
163
|
+
'current:',
|
|
164
|
+
'target:',
|
|
165
|
+
'dependency_category:',
|
|
166
|
+
'suggested_seam:',
|
|
167
|
+
'adapters_planned:',
|
|
168
|
+
]) {
|
|
169
|
+
assert.ok(block.includes(field), `proposal payload missing field "${field}"`);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
@@ -115,10 +115,9 @@ test('generateManifest defaults version when missing', () => {
|
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
test('generateManifest declares artifact convention for OpenClaw skills', () => {
|
|
118
|
-
const manifest = openclaw.generateManifest(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
);
|
|
118
|
+
const manifest = openclaw.generateManifest([{ name: 'cook', layer: 'L1', group: 'orchestrator' }], {
|
|
119
|
+
version: '1.0.0',
|
|
120
|
+
});
|
|
122
121
|
assert.ok(manifest.artifactConvention, 'artifactConvention field exists');
|
|
123
122
|
assert.ok(Array.isArray(manifest.artifactConvention.outputDirPriority), 'outputDirPriority is array');
|
|
124
123
|
assert.ok(manifest.artifactConvention.outputDirPriority.length >= 4, 'at least 4 fallback tiers');
|
|
@@ -129,12 +128,18 @@ test('generateManifest declares artifact convention for OpenClaw skills', () =>
|
|
|
129
128
|
assert.ok(manifest.artifactConvention.outputContract, 'outputContract documented');
|
|
130
129
|
assert.strictEqual(manifest.artifactConvention.outputContract.exitCodes[0], 'success');
|
|
131
130
|
assert.strictEqual(manifest.artifactConvention.outputContract.exitCodes[4], 'timeout with partial results (accept)');
|
|
132
|
-
assert.strictEqual(
|
|
131
|
+
assert.strictEqual(
|
|
132
|
+
manifest.artifactConvention.outputContract.exitCodes[124],
|
|
133
|
+
'timeout with zero results (retry or abort)',
|
|
134
|
+
);
|
|
133
135
|
});
|
|
134
136
|
|
|
135
137
|
test('generateManifest description scales with skill count', () => {
|
|
136
138
|
const fewSkills = openclaw.generateManifest(
|
|
137
|
-
[
|
|
139
|
+
[
|
|
140
|
+
{ name: 'cook', layer: 'L1' },
|
|
141
|
+
{ name: 'plan', layer: 'L2' },
|
|
142
|
+
],
|
|
138
143
|
{ version: '1.0.0' },
|
|
139
144
|
);
|
|
140
145
|
assert.ok(fewSkills.description.includes('2-skill'), 'description reflects actual skill count');
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .out-of-scope/ KB format validation.
|
|
3
|
+
*
|
|
4
|
+
* Validates:
|
|
5
|
+
* - Frontmatter parses as YAML
|
|
6
|
+
* - Required fields present (concept, aliases, decision, rejected_at, rejected_by, prior_requests)
|
|
7
|
+
* - decision == "rejected" (deferrals don't belong here)
|
|
8
|
+
* - concept matches filename (slug rule)
|
|
9
|
+
* - prior_requests has >=1 entry
|
|
10
|
+
* - Slug rules: kebab-case, lowercase, max 40 chars
|
|
11
|
+
*
|
|
12
|
+
* Operates on test fixtures (no real .out-of-scope/ in this repo yet).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from 'node:assert';
|
|
16
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { describe, test } from 'node:test';
|
|
20
|
+
|
|
21
|
+
// Minimal YAML frontmatter parser — we only need flat key:value + simple lists/objects.
|
|
22
|
+
// Mirrors what ba and review-intake expect at runtime.
|
|
23
|
+
function parseFrontmatter(text) {
|
|
24
|
+
const match = text.match(/^---\n([\s\S]*?)\n---/);
|
|
25
|
+
if (!match) return null;
|
|
26
|
+
const block = match[1];
|
|
27
|
+
const lines = block.split('\n');
|
|
28
|
+
const out = {};
|
|
29
|
+
let currentKey = null;
|
|
30
|
+
let currentList = null;
|
|
31
|
+
let currentObj = null;
|
|
32
|
+
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
if (!line.trim()) continue;
|
|
35
|
+
if (line.startsWith(' - ')) {
|
|
36
|
+
// List item, or object-list item starting with `- key: value`
|
|
37
|
+
const inner = line.slice(4);
|
|
38
|
+
if (inner.includes(': ')) {
|
|
39
|
+
// First field of a new object in the list
|
|
40
|
+
currentObj = {};
|
|
41
|
+
const [k, v] = inner.split(/:\s+/);
|
|
42
|
+
currentObj[k.trim()] = stripQuotes(v.trim());
|
|
43
|
+
currentList.push(currentObj);
|
|
44
|
+
} else {
|
|
45
|
+
currentList.push(stripQuotes(inner.trim()));
|
|
46
|
+
currentObj = null;
|
|
47
|
+
}
|
|
48
|
+
} else if (line.startsWith(' ')) {
|
|
49
|
+
// Continuation field of the current object
|
|
50
|
+
if (currentObj && line.includes(': ')) {
|
|
51
|
+
const [k, v] = line.trim().split(/:\s+/);
|
|
52
|
+
currentObj[k.trim()] = stripQuotes(v.trim());
|
|
53
|
+
}
|
|
54
|
+
} else if (line.match(/^[a-z_]+:/)) {
|
|
55
|
+
const [key, ...rest] = line.split(':');
|
|
56
|
+
const value = rest.join(':').trim();
|
|
57
|
+
currentKey = key.trim();
|
|
58
|
+
currentObj = null;
|
|
59
|
+
if (value === '' || value === '[]') {
|
|
60
|
+
out[currentKey] = [];
|
|
61
|
+
currentList = out[currentKey];
|
|
62
|
+
} else if (value.startsWith('[') && value.endsWith(']')) {
|
|
63
|
+
out[currentKey] = value
|
|
64
|
+
.slice(1, -1)
|
|
65
|
+
.split(',')
|
|
66
|
+
.map((s) => stripQuotes(s.trim()))
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
currentList = null;
|
|
69
|
+
} else {
|
|
70
|
+
out[currentKey] = stripQuotes(value);
|
|
71
|
+
currentList = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function stripQuotes(s) {
|
|
79
|
+
if (!s) return s;
|
|
80
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
81
|
+
return s.slice(1, -1);
|
|
82
|
+
}
|
|
83
|
+
return s;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const REQUIRED_FIELDS = ['concept', 'aliases', 'decision', 'rejected_at', 'rejected_by', 'prior_requests'];
|
|
87
|
+
const SLUG_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
88
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
89
|
+
|
|
90
|
+
function validateOutOfScope(filename, content) {
|
|
91
|
+
const issues = [];
|
|
92
|
+
const fm = parseFrontmatter(content);
|
|
93
|
+
if (!fm) return ['frontmatter missing or unparseable'];
|
|
94
|
+
|
|
95
|
+
for (const f of REQUIRED_FIELDS) {
|
|
96
|
+
if (!(f in fm) || fm[f] === undefined) issues.push(`missing field: ${f}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (fm.decision && fm.decision !== 'rejected') {
|
|
100
|
+
issues.push(`decision must be "rejected" (got "${fm.decision}") — deferrals don't belong in .out-of-scope/`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (fm.concept) {
|
|
104
|
+
if (!SLUG_RE.test(fm.concept)) issues.push(`concept slug malformed: ${fm.concept}`);
|
|
105
|
+
if (fm.concept.length > 40) issues.push(`concept slug too long (>40 chars): ${fm.concept}`);
|
|
106
|
+
const expected = path.basename(filename, '.md');
|
|
107
|
+
if (fm.concept !== expected) {
|
|
108
|
+
issues.push(`concept "${fm.concept}" does not match filename "${expected}"`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (fm.rejected_at && !ISO_DATE_RE.test(fm.rejected_at)) {
|
|
113
|
+
issues.push(`rejected_at must be ISO date YYYY-MM-DD (got "${fm.rejected_at}")`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (Array.isArray(fm.prior_requests) && fm.prior_requests.length === 0) {
|
|
117
|
+
issues.push('prior_requests must have >=1 entry');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return issues;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Tests ---
|
|
124
|
+
|
|
125
|
+
describe('out-of-scope/ KB format', () => {
|
|
126
|
+
let tmpDir;
|
|
127
|
+
|
|
128
|
+
test('valid file passes', () => {
|
|
129
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'rune-oos-'));
|
|
130
|
+
const file = path.join(tmpDir, 'dark-mode.md');
|
|
131
|
+
const content = `---
|
|
132
|
+
concept: dark-mode
|
|
133
|
+
aliases: [night-theme, dark-theme]
|
|
134
|
+
decision: rejected
|
|
135
|
+
rejected_at: 2026-04-27
|
|
136
|
+
rejected_by: review-intake
|
|
137
|
+
priority_to_revisit: low
|
|
138
|
+
prior_requests:
|
|
139
|
+
- id: gh-issue-42
|
|
140
|
+
summary: Add dark mode support
|
|
141
|
+
closed_at: 2025-08-01
|
|
142
|
+
revisit_if:
|
|
143
|
+
- "team adds front-end engineer"
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
# Dark Mode
|
|
147
|
+
|
|
148
|
+
Body content.
|
|
149
|
+
`;
|
|
150
|
+
writeFileSync(file, content);
|
|
151
|
+
const issues = validateOutOfScope(file, content);
|
|
152
|
+
assert.deepStrictEqual(issues, []);
|
|
153
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('rejects file with decision=deferred', () => {
|
|
157
|
+
const content = `---
|
|
158
|
+
concept: dark-mode
|
|
159
|
+
aliases: [night-theme]
|
|
160
|
+
decision: deferred
|
|
161
|
+
rejected_at: 2026-04-27
|
|
162
|
+
rejected_by: ba
|
|
163
|
+
prior_requests:
|
|
164
|
+
- id: gh-issue-42
|
|
165
|
+
summary: x
|
|
166
|
+
closed_at: 2025-08-01
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
body
|
|
170
|
+
`;
|
|
171
|
+
const issues = validateOutOfScope('dark-mode.md', content);
|
|
172
|
+
assert.ok(
|
|
173
|
+
issues.some((i) => i.includes('decision must be "rejected"')),
|
|
174
|
+
'should reject decision=deferred',
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('rejects mismatched filename and concept', () => {
|
|
179
|
+
const content = `---
|
|
180
|
+
concept: foo-bar
|
|
181
|
+
aliases: []
|
|
182
|
+
decision: rejected
|
|
183
|
+
rejected_at: 2026-04-27
|
|
184
|
+
rejected_by: ba
|
|
185
|
+
prior_requests:
|
|
186
|
+
- id: i
|
|
187
|
+
summary: s
|
|
188
|
+
closed_at: 2025-08-01
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
body
|
|
192
|
+
`;
|
|
193
|
+
const issues = validateOutOfScope('different-name.md', content);
|
|
194
|
+
assert.ok(
|
|
195
|
+
issues.some((i) => i.includes('does not match filename')),
|
|
196
|
+
'should flag filename/concept mismatch',
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('rejects empty prior_requests', () => {
|
|
201
|
+
const content = `---
|
|
202
|
+
concept: dark-mode
|
|
203
|
+
aliases: []
|
|
204
|
+
decision: rejected
|
|
205
|
+
rejected_at: 2026-04-27
|
|
206
|
+
rejected_by: ba
|
|
207
|
+
prior_requests: []
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
body
|
|
211
|
+
`;
|
|
212
|
+
const issues = validateOutOfScope('dark-mode.md', content);
|
|
213
|
+
assert.ok(
|
|
214
|
+
issues.some((i) => i.includes('prior_requests must have >=1 entry')),
|
|
215
|
+
'should require prior_requests',
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('rejects malformed slug', () => {
|
|
220
|
+
const content = `---
|
|
221
|
+
concept: Dark_Mode
|
|
222
|
+
aliases: []
|
|
223
|
+
decision: rejected
|
|
224
|
+
rejected_at: 2026-04-27
|
|
225
|
+
rejected_by: ba
|
|
226
|
+
prior_requests:
|
|
227
|
+
- id: i
|
|
228
|
+
summary: s
|
|
229
|
+
closed_at: 2025-08-01
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
body
|
|
233
|
+
`;
|
|
234
|
+
const issues = validateOutOfScope('Dark_Mode.md', content);
|
|
235
|
+
assert.ok(
|
|
236
|
+
issues.some((i) => i.includes('concept slug malformed')),
|
|
237
|
+
'should reject non-kebab-case slug',
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('rejects slug longer than 40 chars', () => {
|
|
242
|
+
const longSlug = 'a-very-long-concept-name-that-exceeds-the-forty-char-cap';
|
|
243
|
+
const content = `---
|
|
244
|
+
concept: ${longSlug}
|
|
245
|
+
aliases: []
|
|
246
|
+
decision: rejected
|
|
247
|
+
rejected_at: 2026-04-27
|
|
248
|
+
rejected_by: ba
|
|
249
|
+
prior_requests:
|
|
250
|
+
- id: i
|
|
251
|
+
summary: s
|
|
252
|
+
closed_at: 2025-08-01
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
body
|
|
256
|
+
`;
|
|
257
|
+
const issues = validateOutOfScope(`${longSlug}.md`, content);
|
|
258
|
+
assert.ok(
|
|
259
|
+
issues.some((i) => i.includes('too long')),
|
|
260
|
+
'should reject overlong slug',
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('rejects malformed rejected_at date', () => {
|
|
265
|
+
const content = `---
|
|
266
|
+
concept: dark-mode
|
|
267
|
+
aliases: []
|
|
268
|
+
decision: rejected
|
|
269
|
+
rejected_at: yesterday
|
|
270
|
+
rejected_by: ba
|
|
271
|
+
prior_requests:
|
|
272
|
+
- id: i
|
|
273
|
+
summary: s
|
|
274
|
+
closed_at: 2025-08-01
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
body
|
|
278
|
+
`;
|
|
279
|
+
const issues = validateOutOfScope('dark-mode.md', content);
|
|
280
|
+
assert.ok(
|
|
281
|
+
issues.some((i) => i.includes('ISO date')),
|
|
282
|
+
'should reject non-ISO date',
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('rejects file missing required fields', () => {
|
|
287
|
+
const content = `---
|
|
288
|
+
concept: dark-mode
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
body
|
|
292
|
+
`;
|
|
293
|
+
const issues = validateOutOfScope('dark-mode.md', content);
|
|
294
|
+
assert.ok(issues.length >= 4, `expected multiple missing-field errors, got ${issues.length}`);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('signal name validity', () => {
|
|
299
|
+
test('outofscope.match conforms to signal naming pattern', () => {
|
|
300
|
+
const SIGNAL_PATTERN = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/;
|
|
301
|
+
assert.ok(SIGNAL_PATTERN.test('outofscope.match'));
|
|
302
|
+
});
|
|
303
|
+
});
|