@llman-sdd/core 0.1.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/package.json +33 -0
- package/src/archive/freeze.ts +150 -0
- package/src/archive/sevenzip.ts +110 -0
- package/src/change/frontmatter.ts +49 -0
- package/src/change/id.ts +50 -0
- package/src/change/lifecycle.ts +141 -0
- package/src/config/load.ts +48 -0
- package/src/config/schema.ts +136 -0
- package/src/context/indexStore.ts +132 -0
- package/src/context/retrieve.ts +334 -0
- package/src/context/tree.ts +118 -0
- package/src/git/spawnGit.ts +89 -0
- package/src/index.ts +176 -0
- package/src/init/defaultConfig.ts +73 -0
- package/src/init/init.ts +145 -0
- package/src/ports.ts +20 -0
- package/src/report/collect.ts +164 -0
- package/src/report/graph.ts +103 -0
- package/src/report/show.ts +138 -0
- package/src/report/specHelpers.ts +63 -0
- package/src/report/specs.ts +79 -0
- package/src/review/review.ts +142 -0
- package/src/spec/ir.ts +43 -0
- package/src/spec/parser.ts +171 -0
- package/src/spec/reqRegistry.ts +38 -0
- package/src/templates/engine.ts +51 -0
- package/src/templates/locale.ts +24 -0
- package/src/templates/skills.ts +165 -0
- package/src/validation/discover.ts +30 -0
- package/src/validation/validate.ts +153 -0
package/src/spec/ir.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec IR (spec-parsing capability): pure data shapes produced by
|
|
3
|
+
* parseCapability() and consumed by validation (Phase 3) and downstream
|
|
4
|
+
* commands. No IO of any kind lives here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface CapabilityHeader {
|
|
8
|
+
capability: string | null;
|
|
9
|
+
purpose: string | null;
|
|
10
|
+
scope: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ScenarioClassification = 'human' | 'executable' | 'unclassified';
|
|
14
|
+
|
|
15
|
+
export interface ScenarioIR {
|
|
16
|
+
name: string;
|
|
17
|
+
/** Tag names without the leading `@`. */
|
|
18
|
+
tags: string[];
|
|
19
|
+
/** `@req:rN` links, normalized to `rN`. */
|
|
20
|
+
reqIds: string[];
|
|
21
|
+
classification: ScenarioClassification;
|
|
22
|
+
manual: boolean;
|
|
23
|
+
/** Rule statement (description lines, trimmed) + step texts for executables. */
|
|
24
|
+
statement: string;
|
|
25
|
+
stepCount: number;
|
|
26
|
+
/** Executable-scenario steps with their keyword kinds (context-index tree). */
|
|
27
|
+
steps: { kind: 'given' | 'when' | 'then'; text: string }[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SpecStructuralError {
|
|
31
|
+
/** Machine-ish anchor, e.g. `missing-header:purpose` or `scenario:规则样例`. */
|
|
32
|
+
code: string;
|
|
33
|
+
message: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CapabilityDoc {
|
|
37
|
+
fileName: string;
|
|
38
|
+
header: CapabilityHeader;
|
|
39
|
+
featureName: string;
|
|
40
|
+
language: string;
|
|
41
|
+
scenarios: ScenarioIR[];
|
|
42
|
+
errors: SpecStructuralError[];
|
|
43
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-track capability .feature parsing (spec-parsing capability).
|
|
3
|
+
*
|
|
4
|
+
* Language fallback chain (r7): start with the `en` matcher — a
|
|
5
|
+
* `# language:` header switches dialect automatically mid-scan; on failure
|
|
6
|
+
* retry with the zh-CN matcher (Chinese keywords without a header); only then
|
|
7
|
+
* surface a parse error. locale zh-Hans maps to gherkin zh-CN.
|
|
8
|
+
*/
|
|
9
|
+
import { AstBuilder, GherkinClassicTokenMatcher, Parser } from '@cucumber/gherkin';
|
|
10
|
+
import type { GherkinDocument } from '@cucumber/messages';
|
|
11
|
+
|
|
12
|
+
import type { CapabilityDoc, CapabilityHeader, ScenarioIR, SpecStructuralError } from './ir.ts';
|
|
13
|
+
|
|
14
|
+
export class SpecParseError extends Error {}
|
|
15
|
+
|
|
16
|
+
let nodeCounter = 0;
|
|
17
|
+
|
|
18
|
+
export function localeToGherkinLang(locale: string): string {
|
|
19
|
+
return locale === 'zh-Hans' ? 'zh-CN' : locale;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const makeIdGenerator = (): (() => string) => {
|
|
23
|
+
const base = ++nodeCounter;
|
|
24
|
+
let i = 0;
|
|
25
|
+
return () => `n-${base}-${++i}`;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function parseFeatureSource(source: string): { doc: GherkinDocument; language: string } {
|
|
29
|
+
let lastError: unknown = null;
|
|
30
|
+
for (const dialect of ['en', 'zh-CN'] as const) {
|
|
31
|
+
try {
|
|
32
|
+
const parser = new Parser(
|
|
33
|
+
new AstBuilder(makeIdGenerator()),
|
|
34
|
+
new GherkinClassicTokenMatcher(dialect),
|
|
35
|
+
);
|
|
36
|
+
return { doc: parser.parse(source), language: dialect };
|
|
37
|
+
} catch (error) {
|
|
38
|
+
lastError = error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw new SpecParseError(
|
|
42
|
+
`gherkin parse failed (tried en, zh-CN): ${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const REQ_TAG_RE = /^@?req:(r\d+)$/u;
|
|
47
|
+
|
|
48
|
+
/** Gherkin keyword (zh-CN + en) → step kind; And/But/* inherit via fallback. */
|
|
49
|
+
function stepKeywordToKind(keyword: string): 'given' | 'when' | 'then' {
|
|
50
|
+
const kw = keyword.trim();
|
|
51
|
+
if (/^(假如|Given)/iu.test(kw)) return 'given';
|
|
52
|
+
if (/^(当|When)/iu.test(kw)) return 'when';
|
|
53
|
+
if (/^(那么|Then)/iu.test(kw)) return 'then';
|
|
54
|
+
return 'given';
|
|
55
|
+
}
|
|
56
|
+
const HEADER_RE = /^#\s*(capability|purpose|scope):\s*(.*)$/u;
|
|
57
|
+
|
|
58
|
+
function extractHeader(source: string): CapabilityHeader {
|
|
59
|
+
const header: CapabilityHeader = { capability: null, purpose: null, scope: null };
|
|
60
|
+
for (const line of source.split('\n')) {
|
|
61
|
+
if (line.trim() === '') continue;
|
|
62
|
+
if (!line.trimStart().startsWith('#')) break;
|
|
63
|
+
const m = line.trimStart().match(HEADER_RE);
|
|
64
|
+
if (m?.[1]) {
|
|
65
|
+
const key = m[1] as 'capability' | 'purpose' | 'scope';
|
|
66
|
+
header[key] = m[2]?.trim() ?? null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return header;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function classify(tags: string[]): {
|
|
73
|
+
classification: ScenarioIR['classification'];
|
|
74
|
+
manual: boolean;
|
|
75
|
+
errors: SpecStructuralError[];
|
|
76
|
+
name: string;
|
|
77
|
+
} {
|
|
78
|
+
const errors: SpecStructuralError[] = [];
|
|
79
|
+
const has = (t: string): boolean => tags.includes(t);
|
|
80
|
+
const human = has('human');
|
|
81
|
+
const executable = has('executable');
|
|
82
|
+
const manual = has('manual');
|
|
83
|
+
const label = tags.join(',');
|
|
84
|
+
|
|
85
|
+
if (manual && !human) {
|
|
86
|
+
errors.push({
|
|
87
|
+
code: 'tag:manual-orphan',
|
|
88
|
+
message: `@manual 必须与 @human 同用(tags: ${label})`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (human && executable) {
|
|
92
|
+
errors.push({
|
|
93
|
+
code: 'tag:mutually-exclusive',
|
|
94
|
+
message: `@human 与 @executable 互斥(tags: ${label})`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const classification: ScenarioIR['classification'] = human
|
|
98
|
+
? 'human'
|
|
99
|
+
: executable
|
|
100
|
+
? 'executable'
|
|
101
|
+
: 'unclassified';
|
|
102
|
+
return { classification, manual, errors, name: label };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Parse one capability .feature source into the single-track IR. */
|
|
106
|
+
export function parseCapability(source: string, fileName = '<inline>'): CapabilityDoc {
|
|
107
|
+
const errors: SpecStructuralError[] = [];
|
|
108
|
+
const header = extractHeader(source);
|
|
109
|
+
for (const key of ['capability', 'purpose', 'scope'] as const) {
|
|
110
|
+
if (header[key] === null) {
|
|
111
|
+
errors.push({ code: `missing-header:${key}`, message: `missing # ${key}: header comment` });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { doc, language } = parseFeatureSource(source);
|
|
116
|
+
const feature = doc.feature;
|
|
117
|
+
const featureName = feature?.name ?? '';
|
|
118
|
+
const scenarios: ScenarioIR[] = [];
|
|
119
|
+
|
|
120
|
+
for (const child of feature?.children ?? []) {
|
|
121
|
+
const rule = child.rule;
|
|
122
|
+
if (rule && rule.children.some((c) => c.scenario)) {
|
|
123
|
+
errors.push({
|
|
124
|
+
code: 'rule:nested-scenario',
|
|
125
|
+
message: `Rule 块内嵌场景被拒绝(rule: ${rule.name})`,
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const scenario = child.scenario;
|
|
130
|
+
if (!scenario) continue;
|
|
131
|
+
|
|
132
|
+
const tags = scenario.tags.map((t) => t.name.replace(/^@/u, ''));
|
|
133
|
+
const reqIds = scenario.tags
|
|
134
|
+
.map((t) => t.name.match(REQ_TAG_RE)?.[1])
|
|
135
|
+
.filter((v): v is string => v !== undefined);
|
|
136
|
+
|
|
137
|
+
const kind = classify(tags);
|
|
138
|
+
errors.push(...kind.errors);
|
|
139
|
+
|
|
140
|
+
const description = (scenario.description ?? '')
|
|
141
|
+
.split('\n')
|
|
142
|
+
.map((l) => l.trim())
|
|
143
|
+
.filter((l) => l !== '');
|
|
144
|
+
const stepTexts = scenario.steps.map((s) => s.text.trim());
|
|
145
|
+
const statement = [...description, ...stepTexts].join('\n');
|
|
146
|
+
const steps = scenario.steps.map((s) => ({
|
|
147
|
+
kind: stepKeywordToKind(s.keyword),
|
|
148
|
+
text: s.text.trim(),
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
if (kind.classification === 'human' && !/\bMUST\b|\bSHALL\b/u.test(statement)) {
|
|
152
|
+
errors.push({
|
|
153
|
+
code: 'rule:missing-must-word',
|
|
154
|
+
message: `@human 规则场景描述必须含 MUST/SHALL(scenario: ${scenario.name})`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
scenarios.push({
|
|
159
|
+
name: scenario.name,
|
|
160
|
+
tags,
|
|
161
|
+
reqIds,
|
|
162
|
+
classification: kind.classification,
|
|
163
|
+
manual: kind.manual,
|
|
164
|
+
statement,
|
|
165
|
+
stepCount: stepTexts.length,
|
|
166
|
+
steps,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { fileName, header, featureName, language, scenarios, errors };
|
|
171
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global rN registry (spec-parsing capability): @req:rN ids form a single
|
|
3
|
+
* global namespace across all capability specs; duplicates are reported with
|
|
4
|
+
* the conflicting file pairs.
|
|
5
|
+
*/
|
|
6
|
+
import type { CapabilityDoc } from './ir.ts';
|
|
7
|
+
|
|
8
|
+
export interface RegistryDuplicate {
|
|
9
|
+
reqId: string;
|
|
10
|
+
files: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ReqRegistry {
|
|
14
|
+
/** reqId → files referencing it (sorted). */
|
|
15
|
+
byId: Map<string, string[]>;
|
|
16
|
+
duplicates: RegistryDuplicate[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildReqRegistry(
|
|
20
|
+
docs: readonly { fileName: string; doc: CapabilityDoc }[],
|
|
21
|
+
): ReqRegistry {
|
|
22
|
+
const byId = new Map<string, string[]>();
|
|
23
|
+
for (const { fileName, doc } of docs) {
|
|
24
|
+
for (const scenario of doc.scenarios) {
|
|
25
|
+
for (const reqId of scenario.reqIds) {
|
|
26
|
+
const files = byId.get(reqId) ?? [];
|
|
27
|
+
if (!files.includes(fileName)) files.push(fileName);
|
|
28
|
+
byId.set(reqId, files);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const duplicates: RegistryDuplicate[] = [];
|
|
33
|
+
for (const [reqId, files] of byId) {
|
|
34
|
+
if (files.length > 1) duplicates.push({ reqId, files: files.toSorted() });
|
|
35
|
+
}
|
|
36
|
+
duplicates.sort((a, b) => a.reqId.localeCompare(b.reqId));
|
|
37
|
+
return { byId, duplicates };
|
|
38
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template engine (init-generators capability, r17): nunjucks adapter with
|
|
3
|
+
* minijinja-compatible semantics used by v1 — string-only globals, undefined
|
|
4
|
+
* renders empty (Lenient), nested `unit(id)` expansion capped at 32, and
|
|
5
|
+
* trailing whitespace trimmed from the final product.
|
|
6
|
+
*/
|
|
7
|
+
import * as nunjucks from 'nunjucks';
|
|
8
|
+
|
|
9
|
+
export const MAX_UNIT_NESTING_DEPTH = 32;
|
|
10
|
+
|
|
11
|
+
export type UnitRegistry = Map<string, string>;
|
|
12
|
+
|
|
13
|
+
export class MissingUnitError extends Error {
|
|
14
|
+
constructor(id: string) {
|
|
15
|
+
super(`missing template unit '${id}'`);
|
|
16
|
+
this.name = 'MissingUnitError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function renderWithUnits(
|
|
21
|
+
raw: string,
|
|
22
|
+
units: UnitRegistry,
|
|
23
|
+
vars: Record<string, string>,
|
|
24
|
+
depth = 0,
|
|
25
|
+
): string {
|
|
26
|
+
if (depth > MAX_UNIT_NESTING_DEPTH) {
|
|
27
|
+
throw new Error(`unit nesting exceeded ${MAX_UNIT_NESTING_DEPTH}`);
|
|
28
|
+
}
|
|
29
|
+
// minijinja keep_trailing_newline=false: a single trailing newline of the
|
|
30
|
+
// template SOURCE is stripped before rendering (v1 parity).
|
|
31
|
+
const source = raw.replace(/\n$/u, '').replace(/\r\n$/u, '');
|
|
32
|
+
const env = new nunjucks.Environment(null, { autoescape: false });
|
|
33
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
34
|
+
env.addGlobal(key, value);
|
|
35
|
+
}
|
|
36
|
+
env.addGlobal('unit', (id: string): string => {
|
|
37
|
+
const content = units.get(id);
|
|
38
|
+
if (content === undefined) throw new MissingUnitError(id);
|
|
39
|
+
return renderWithUnits(content, units, vars, depth + 1);
|
|
40
|
+
});
|
|
41
|
+
return env.renderString(source, {});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Render a template product: lenient render + trailing-whitespace trim. */
|
|
45
|
+
export function renderTemplate(
|
|
46
|
+
raw: string,
|
|
47
|
+
units: UnitRegistry,
|
|
48
|
+
vars: Record<string, string>,
|
|
49
|
+
): string {
|
|
50
|
+
return renderWithUnits(raw, units, vars, 0).trimEnd();
|
|
51
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale normalization + fallback chain (init-generators capability, r18).
|
|
3
|
+
* Port of v1 config.rs normalize_locale / locale_fallbacks.
|
|
4
|
+
*/
|
|
5
|
+
export function normalizeLocale(value: string): string {
|
|
6
|
+
const trimmed = value.trim();
|
|
7
|
+
if (trimmed === '') return 'en';
|
|
8
|
+
const lower = trimmed.toLowerCase();
|
|
9
|
+
if (lower === 'zh' || lower.startsWith('zh-hans') || lower === 'zh-cn') return 'zh-Hans';
|
|
10
|
+
if (lower.startsWith('en')) return 'en';
|
|
11
|
+
return trimmed;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function localeFallbacks(locale: string): string[] {
|
|
15
|
+
const normalized = normalizeLocale(locale);
|
|
16
|
+
const locales: string[] = [normalized];
|
|
17
|
+
const dash = normalized.indexOf('-');
|
|
18
|
+
if (dash !== -1) {
|
|
19
|
+
const lang = normalized.slice(0, dash);
|
|
20
|
+
if (!locales.includes(lang)) locales.push(lang);
|
|
21
|
+
}
|
|
22
|
+
if (!locales.includes('en')) locales.push('en');
|
|
23
|
+
return locales;
|
|
24
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { SddConfig } from '../config/schema.ts';
|
|
2
|
+
import { renderTemplate, type UnitRegistry } from './engine.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Skills rendering + init orchestration (init-generators capability, r19).
|
|
5
|
+
* Port of v1 templates.rs / update_skills.rs / init.rs semantics.
|
|
6
|
+
*/
|
|
7
|
+
import { localeFallbacks } from './locale.ts';
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_SKILL_FILES: readonly string[] = [
|
|
10
|
+
'llman-sdd-explore.md',
|
|
11
|
+
'llman-sdd-propose.md',
|
|
12
|
+
'llman-sdd-draft.md',
|
|
13
|
+
'llman-sdd-apply.md',
|
|
14
|
+
'llman-sdd-verify.md',
|
|
15
|
+
'llman-sdd-quick.md',
|
|
16
|
+
'llman-sdd-specs-compact.md',
|
|
17
|
+
'llman-sdd-archive.md',
|
|
18
|
+
'llman-sdd-graph.md',
|
|
19
|
+
'llman-sdd-apply-cycle.md',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export const OPTIONAL_SKILL_FILES: readonly string[] = [
|
|
23
|
+
'llman-sdd-continue.md',
|
|
24
|
+
'llman-sdd-ff.md',
|
|
25
|
+
'llman-sdd-validate.md',
|
|
26
|
+
'llman-sdd-arch-review.md',
|
|
27
|
+
'llman-sdd-wayfinder.md',
|
|
28
|
+
'llman-sdd-research.md',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export const UNIT_FILES: readonly string[] = [
|
|
32
|
+
'skills/validation-hints.md',
|
|
33
|
+
'skills/human-readable-summary.md',
|
|
34
|
+
'skills/git-native-flow.md',
|
|
35
|
+
'skills/git-native-flow-brief.md',
|
|
36
|
+
'skills/stage-guard.md',
|
|
37
|
+
'skills/ethics-governance.md',
|
|
38
|
+
'spec/feature-contract.md',
|
|
39
|
+
'skills/structured-protocol.md',
|
|
40
|
+
'workflow/archive-freeze-guidance.md',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export const ETHICS_KEYS: readonly string[] = [
|
|
44
|
+
'ethics.risk_level',
|
|
45
|
+
'ethics.prohibited_actions',
|
|
46
|
+
'ethics.required_evidence',
|
|
47
|
+
'ethics.refusal_contract',
|
|
48
|
+
'ethics.escalation_policy',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** Framework-derived run_command (v1 config.rs effective_run_command). */
|
|
52
|
+
export function effectiveRunCommand(bdd: NonNullable<SddConfig['bdd']>): string {
|
|
53
|
+
if (bdd.run_command) return bdd.run_command;
|
|
54
|
+
switch (bdd.framework ?? '') {
|
|
55
|
+
case 'pytest-bdd':
|
|
56
|
+
return 'pytest {feature_dir} -k {feature_name} -v';
|
|
57
|
+
case 'rstest-bdd':
|
|
58
|
+
return 'cargo test --features bdd';
|
|
59
|
+
case 'cucumber-js':
|
|
60
|
+
return 'npx cucumber-js {feature_path}';
|
|
61
|
+
case 'behave':
|
|
62
|
+
return 'behave {feature_path}';
|
|
63
|
+
default:
|
|
64
|
+
return "echo 'No run_command configured. Set bdd.run_command in config.yaml'";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** All-string globals (v1 BTreeMap<String, String> semantics). */
|
|
69
|
+
export function buildTemplateVars(config: SddConfig, version: string): Record<string, string> {
|
|
70
|
+
const vars: Record<string, string> = { llman_version: version };
|
|
71
|
+
if (config.bdd) {
|
|
72
|
+
vars['bdd_enabled'] = 'true';
|
|
73
|
+
vars['bdd_framework'] = config.bdd.framework ?? '';
|
|
74
|
+
if (config.bdd.feature_dir) vars['bdd_feature_dir'] = config.bdd.feature_dir;
|
|
75
|
+
vars['bdd_run_command'] = effectiveRunCommand(config.bdd);
|
|
76
|
+
if (config.bdd.default_language) vars['bdd_default_language'] = config.bdd.default_language;
|
|
77
|
+
if (config.bdd.verify_prompt) vars['bdd_verify_prompt'] = config.bdd.verify_prompt;
|
|
78
|
+
}
|
|
79
|
+
const extras = new Set<string>(config.extra_skills ?? []);
|
|
80
|
+
for (const name of OPTIONAL_SKILL_FILES) {
|
|
81
|
+
const stem = name.replace(/\.md$/u, '');
|
|
82
|
+
const key = `extra_skill_${stem.replace(/^llman-sdd-/u, '').replaceAll('-', '_')}`;
|
|
83
|
+
if (extras.has(stem)) vars[key] = 'true';
|
|
84
|
+
}
|
|
85
|
+
return vars;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface TemplateIo {
|
|
89
|
+
exists(path: string): boolean;
|
|
90
|
+
readText(path: string): string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Locale-fallback resource load: first locale in the chain that has the file. */
|
|
94
|
+
export function loadLocaleResource(
|
|
95
|
+
io: TemplateIo,
|
|
96
|
+
templatesRoot: string,
|
|
97
|
+
locales: readonly string[],
|
|
98
|
+
relativePath: string,
|
|
99
|
+
): string | null {
|
|
100
|
+
for (const locale of locales) {
|
|
101
|
+
const path = `${templatesRoot}/${locale}/${relativePath}`;
|
|
102
|
+
if (io.exists(path)) return io.readText(path);
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function loadUnitRegistry(
|
|
108
|
+
io: TemplateIo,
|
|
109
|
+
templatesRoot: string,
|
|
110
|
+
locales: readonly string[],
|
|
111
|
+
): UnitRegistry {
|
|
112
|
+
const units: UnitRegistry = new Map();
|
|
113
|
+
for (const unitFile of UNIT_FILES) {
|
|
114
|
+
const id = unitFile.replace(/\.md$/u, '');
|
|
115
|
+
const content = loadLocaleResource(io, templatesRoot, locales, `units/${unitFile}`);
|
|
116
|
+
if (content !== null) units.set(id, content);
|
|
117
|
+
}
|
|
118
|
+
return units;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface SkillTemplate {
|
|
122
|
+
name: string;
|
|
123
|
+
content: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function skillCandidates(config: SddConfig): string[] {
|
|
127
|
+
const extras: readonly string[] = config.extra_skills ?? [];
|
|
128
|
+
return [
|
|
129
|
+
...DEFAULT_SKILL_FILES,
|
|
130
|
+
...OPTIONAL_SKILL_FILES.filter((f) => {
|
|
131
|
+
const stem = f.replace(/\.md$/u, '');
|
|
132
|
+
return extras.includes(stem);
|
|
133
|
+
}),
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function loadSkillTemplates(
|
|
138
|
+
io: TemplateIo,
|
|
139
|
+
templatesRoot: string,
|
|
140
|
+
config: SddConfig,
|
|
141
|
+
vars: Record<string, string>,
|
|
142
|
+
): SkillTemplate[] {
|
|
143
|
+
const locales = localeFallbacks(config.locale);
|
|
144
|
+
const units = loadUnitRegistry(io, templatesRoot, locales);
|
|
145
|
+
const templates: SkillTemplate[] = [];
|
|
146
|
+
for (const skillFile of skillCandidates(config)) {
|
|
147
|
+
const raw = loadLocaleResource(io, templatesRoot, locales, `skills/${skillFile}`);
|
|
148
|
+
if (raw === null) {
|
|
149
|
+
throw new Error(`template not found: skills/${skillFile}`);
|
|
150
|
+
}
|
|
151
|
+
templates.push({ name: skillFile, content: renderTemplate(raw, units, vars) });
|
|
152
|
+
}
|
|
153
|
+
return templates;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Ethics gate: every rendered skill must carry all five governance keys. */
|
|
157
|
+
export function enforceEthicsGovernance(templates: readonly SkillTemplate[]): void {
|
|
158
|
+
for (const t of templates) {
|
|
159
|
+
for (const key of ETHICS_KEYS) {
|
|
160
|
+
if (!t.content.includes(key)) {
|
|
161
|
+
throw new Error(`missing required ethics governance key '${key}' in template '${t.name}'`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { parseCapability } from '../spec/parser.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Spec discovery (validation capability): walk llmanspec/specs/** for
|
|
4
|
+
* .feature files via injected IO — core stays filesystem-free.
|
|
5
|
+
*/
|
|
6
|
+
import type { SpecEntry } from './validate.ts';
|
|
7
|
+
|
|
8
|
+
export interface DiscoveryIo {
|
|
9
|
+
exists(path: string): boolean;
|
|
10
|
+
isDirectory(path: string): boolean;
|
|
11
|
+
listDir(path: string): string[];
|
|
12
|
+
readText(path: string): string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function discoverSpecs(specsDir: string, io: DiscoveryIo): SpecEntry[] {
|
|
16
|
+
const walk = (dir: string): string[] => {
|
|
17
|
+
if (!io.exists(dir) || !io.isDirectory(dir)) return [];
|
|
18
|
+
const out: string[] = [];
|
|
19
|
+
for (const name of io.listDir(dir).toSorted()) {
|
|
20
|
+
const full = dir.endsWith('/') ? `${dir}${name}` : `${dir}/${name}`;
|
|
21
|
+
if (io.isDirectory(full)) out.push(...walk(full));
|
|
22
|
+
else if (name.endsWith('.feature')) out.push(full);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
};
|
|
26
|
+
return walk(specsDir).map((path) => ({
|
|
27
|
+
fileName: path,
|
|
28
|
+
doc: parseCapability(io.readText(path), path),
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation engine (validation capability): aggregates Phase-2 parse errors
|
|
3
|
+
* plus the verdict-equivalent gates probed from v1 (r11/r12). Pure —
|
|
4
|
+
* filesystem access is injected via SpecIo.
|
|
5
|
+
*/
|
|
6
|
+
import type { CapabilityDoc } from '../spec/ir.ts';
|
|
7
|
+
import { buildReqRegistry } from '../spec/reqRegistry.ts';
|
|
8
|
+
|
|
9
|
+
export type ValidationLevel = 'ERROR' | 'WARNING' | 'INFO';
|
|
10
|
+
|
|
11
|
+
export interface ValidationItem {
|
|
12
|
+
level: ValidationLevel;
|
|
13
|
+
/** Gate anchor, e.g. `t/rule/ok` or `t/valid_scope` (v1-style). */
|
|
14
|
+
id: string;
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SpecEntry {
|
|
19
|
+
fileName: string;
|
|
20
|
+
doc: CapabilityDoc;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** v1 wording: constraint statements must contain one of these tokens. */
|
|
24
|
+
const MUST_WORD_RE = /\bMUST\b|\bSHALL\b|必须|不得|禁止/u;
|
|
25
|
+
|
|
26
|
+
export interface SpecIo {
|
|
27
|
+
exists(path: string): boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SpecVerdict {
|
|
31
|
+
fileName: string;
|
|
32
|
+
capability: string;
|
|
33
|
+
ok: boolean;
|
|
34
|
+
items: ValidationItem[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ValidationReport {
|
|
38
|
+
verdicts: SpecVerdict[];
|
|
39
|
+
/** Report lines: `OK|FAIL spec/<cap>` entries, `[LEVEL]` details, Totals. */
|
|
40
|
+
lines: string[];
|
|
41
|
+
failed: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function validateCapability(
|
|
45
|
+
entry: SpecEntry,
|
|
46
|
+
duplicatesFor: (reqId: string) => boolean,
|
|
47
|
+
io: SpecIo,
|
|
48
|
+
): SpecVerdict {
|
|
49
|
+
const { doc } = entry;
|
|
50
|
+
const cap = doc.header.capability ?? entry.fileName;
|
|
51
|
+
const items: ValidationItem[] = [];
|
|
52
|
+
|
|
53
|
+
// Header gates (r12): capability header is an ERROR (v1 parity); missing
|
|
54
|
+
// purpose/scope degrade to WARNING.
|
|
55
|
+
if (doc.header.capability === null) {
|
|
56
|
+
items.push({
|
|
57
|
+
level: 'ERROR',
|
|
58
|
+
id: 'file',
|
|
59
|
+
message: 'missing `# capability:` header comment',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (doc.header.purpose === null) {
|
|
63
|
+
items.push({ level: 'WARNING', id: 'file', message: 'missing `# purpose:` header comment' });
|
|
64
|
+
}
|
|
65
|
+
if (doc.header.scope === null) {
|
|
66
|
+
items.push({ level: 'WARNING', id: 'file', message: 'missing `# scope:` header comment' });
|
|
67
|
+
} else {
|
|
68
|
+
for (const p of doc.header.scope
|
|
69
|
+
.split(',')
|
|
70
|
+
.map((s) => s.trim())
|
|
71
|
+
.filter((s) => s !== '')) {
|
|
72
|
+
if (!io.exists(p)) {
|
|
73
|
+
items.push({
|
|
74
|
+
level: 'ERROR',
|
|
75
|
+
id: `${cap}/valid_scope`,
|
|
76
|
+
message: `valid_scope path(s) do not exist on disk: ${p}`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Structural errors from parsing (mutual exclusion, manual orphan, nested
|
|
83
|
+
// rule scenarios) — all ERROR level (v1 verdict parity). Header gates and
|
|
84
|
+
// the MUST-word gate are owned by this layer (mapped below), so the
|
|
85
|
+
// parser's duplicate findings are skipped here.
|
|
86
|
+
const OWNED_BY_THIS_LAYER = new Set(['missing-header:', 'rule:missing-must-word']);
|
|
87
|
+
for (const err of doc.errors) {
|
|
88
|
+
if ([...OWNED_BY_THIS_LAYER].some((prefix) => err.code.startsWith(prefix))) continue;
|
|
89
|
+
items.push({ level: 'ERROR', id: `${cap}/${err.code}`, message: err.message });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const scenario of doc.scenarios) {
|
|
93
|
+
const anchor = `${cap}/rule/${scenario.name}`;
|
|
94
|
+
if (scenario.classification === 'human') {
|
|
95
|
+
if (scenario.reqIds.length === 0) {
|
|
96
|
+
items.push({
|
|
97
|
+
level: 'ERROR',
|
|
98
|
+
id: anchor,
|
|
99
|
+
message: '@human constraint scenario must carry an @req:<req_id> tag',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (!MUST_WORD_RE.test(scenario.statement)) {
|
|
103
|
+
items.push({
|
|
104
|
+
level: 'ERROR',
|
|
105
|
+
id: anchor,
|
|
106
|
+
message: 'constraint statement must contain MUST/SHALL (or 必须/不得/禁止)',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const reqId of scenario.reqIds) {
|
|
111
|
+
if (duplicatesFor(reqId)) {
|
|
112
|
+
items.push({
|
|
113
|
+
level: 'ERROR',
|
|
114
|
+
id: `${cap}/registry/${reqId}`,
|
|
115
|
+
message: `global duplicate req_id \`${reqId}\` used by multiple capabilities`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
fileName: entry.fileName,
|
|
123
|
+
capability: cap,
|
|
124
|
+
ok: !items.some((i) => i.level === 'ERROR'),
|
|
125
|
+
items,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function validateAllSpecs(entries: readonly SpecEntry[], io: SpecIo): ValidationReport {
|
|
130
|
+
const registry = buildReqRegistry(entries);
|
|
131
|
+
const duplicateIds = new Set(registry.duplicates.flatMap((d) => d.reqId));
|
|
132
|
+
// v1 parity: a structural error anywhere aborts the req_id index scan, so
|
|
133
|
+
// the duplicate gate only fires when every spec parses cleanly.
|
|
134
|
+
const structurallyClean = entries.every((e) => e.doc.errors.length === 0);
|
|
135
|
+
const duplicatesFor = (reqId: string): boolean => structurallyClean && duplicateIds.has(reqId);
|
|
136
|
+
|
|
137
|
+
const verdicts = entries.map((e) => validateCapability(e, duplicatesFor, io));
|
|
138
|
+
const failed = verdicts.some((v) => !v.ok);
|
|
139
|
+
const passed = verdicts.filter((v) => v.ok).length;
|
|
140
|
+
|
|
141
|
+
const lines: string[] = [];
|
|
142
|
+
for (const v of verdicts) {
|
|
143
|
+
lines.push(`${v.ok ? 'OK' : 'FAIL'} spec/${v.capability}`);
|
|
144
|
+
for (const item of v.items) {
|
|
145
|
+
lines.push(` [${item.level}] ${item.id}: ${item.message}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
lines.push(
|
|
149
|
+
`Totals: ${passed} passed, ${verdicts.length - passed} failed (${verdicts.length} items)`,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
return { verdicts, lines, failed };
|
|
153
|
+
}
|