fix: support hyphenated speck names + provenance block parsing (v0.3.1)
Some checks failed
Deploy Landing Page to Storj / deploy (push) Failing after 2s
Some checks failed
Deploy Landing Page to Storj / deploy (push) Failing after 2s
- Fixed speck name regex to allow hyphens (\w → [\w-])
- Added provenance { } block handling in parseMember()
- Updated hyphenated name test from 'My' to 'My-Service-Handler'
- All 48 tests passing (27 parser + 21 validator)
This commit is contained in:
parent
a009bacef9
commit
cbee677a11
5 changed files with 403 additions and 6 deletions
390
compiler/__tests__/parser.test.ts
Normal file
390
compiler/__tests__/parser.test.ts
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { parseSpeckContent } from '../src/parser.js';
|
||||
|
||||
describe('SpeckDL Parser', () => {
|
||||
describe('parseSpeckContent', () => {
|
||||
it('should parse a minimal speck definition with name', () => {
|
||||
const content = `speck Minimal {
|
||||
input: Nat
|
||||
output: Bool
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
expect(ast.specks).toHaveLength(1);
|
||||
expect(ast.specks[0].name).toBe('Minimal');
|
||||
});
|
||||
|
||||
it('should parse multiple speck definitions in one file', () => {
|
||||
const content = `speck First {
|
||||
input: Nat
|
||||
output: Bool
|
||||
}
|
||||
|
||||
speck Second {
|
||||
input: String
|
||||
output: Nat
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
expect(ast.specks).toHaveLength(2);
|
||||
expect(ast.specks[0].name).toBe('First');
|
||||
expect(ast.specks[1].name).toBe('Second');
|
||||
});
|
||||
|
||||
it('should return empty specks array for empty content', () => {
|
||||
const ast = parseSpeckContent('');
|
||||
expect(ast.specks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty specks array for comment-only content', () => {
|
||||
const ast = parseSpeckContent('// This is a comment\n/* block comment */\n');
|
||||
expect(ast.specks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip comments inside speck blocks', () => {
|
||||
const content = `speck WithComments {
|
||||
// This is a comment
|
||||
input: Nat
|
||||
/* block comment */
|
||||
output: String
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
expect(ast.specks).toHaveLength(1);
|
||||
expect(ast.specks[0].members).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should parse speck name with hyphenated names', () => {
|
||||
const content = `speck My-Service-Handler {
|
||||
input: Nat
|
||||
output: Bool
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
expect(ast.specks).toHaveLength(1);
|
||||
expect(ast.specks[0].name).toBe('My-Service-Handler');
|
||||
});
|
||||
});
|
||||
|
||||
describe('member parsing - inputs and outputs', () => {
|
||||
it('should parse input with primitive type', () => {
|
||||
const content = `speck Test {
|
||||
input: Nat
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const members = ast.specks[0].members;
|
||||
expect(members).toHaveLength(1);
|
||||
expect(members[0].type).toBe('input');
|
||||
if (members[0].type === 'input') {
|
||||
expect(members[0].typeExpr.type).toBe('primitive');
|
||||
expect(members[0].typeExpr.name).toBe('Nat');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse output with primitive type', () => {
|
||||
const content = `speck Test {
|
||||
output: Bool
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
expect(ast.specks[0].members[0].type).toBe('output');
|
||||
});
|
||||
|
||||
it('should parse input with record type', () => {
|
||||
const content = `speck Test {
|
||||
input: { name: String, age: Nat }
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const input = ast.specks[0].members[0];
|
||||
expect(input.type).toBe('input');
|
||||
if (input.type === 'input') {
|
||||
expect(input.typeExpr.type).toBe('record');
|
||||
expect(input.typeExpr.fields).toHaveLength(2);
|
||||
expect(input.typeExpr.fields![0].name).toBe('name');
|
||||
expect(input.typeExpr.fields![1].name).toBe('age');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse input with list type', () => {
|
||||
const content = `speck Test {
|
||||
input: [Nat]
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const input = ast.specks[0].members[0];
|
||||
if (input.type === 'input') {
|
||||
expect(input.typeExpr.type).toBe('list');
|
||||
expect(input.typeExpr.elementType?.type).toBe('primitive');
|
||||
expect(input.typeExpr.elementType?.name).toBe('Nat');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse all supported primitive types', () => {
|
||||
const primitives = ['Nat', 'Int', 'Real', 'Bool', 'String', 'Bytes'];
|
||||
for (const prim of primitives) {
|
||||
const content = `speck Test {
|
||||
input: ${prim}
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const input = ast.specks[0].members[0];
|
||||
if (input.type === 'input') {
|
||||
expect(input.typeExpr.name).toBe(prim);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('member parsing - constraints', () => {
|
||||
it('should parse simple constraints', () => {
|
||||
const content = `speck Test {
|
||||
input: Nat
|
||||
constraint: x > 0
|
||||
constraint: y <= 100
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const constraints = ast.specks[0].members.filter(m => m.type === 'constraint');
|
||||
expect(constraints).toHaveLength(2);
|
||||
if (constraints[0].type === 'constraint') {
|
||||
expect(constraints[0].expr).toBe('x > 0');
|
||||
}
|
||||
if (constraints[1].type === 'constraint') {
|
||||
expect(constraints[1].expr).toBe('y <= 100');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse complex constraint expressions', () => {
|
||||
const content = `speck Test {
|
||||
input: Nat
|
||||
constraint: implies(safe, volume >= minDose / drugConcentration)
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const constraint = ast.specks[0].members.find(m => m.type === 'constraint');
|
||||
expect(constraint).toBeDefined();
|
||||
if (constraint?.type === 'constraint') {
|
||||
expect(constraint.expr).toContain('implies');
|
||||
expect(constraint.expr).toContain('volume');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('member parsing - verify', () => {
|
||||
it('should parse verify statements', () => {
|
||||
const content = `speck Test {
|
||||
input: Nat
|
||||
verify: always(implies(attempt > 1, delay > 0))
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const verify = ast.specks[0].members.find(m => m.type === 'verify');
|
||||
expect(verify).toBeDefined();
|
||||
if (verify?.type === 'verify') {
|
||||
expect(verify.temporalExpr).toContain('always');
|
||||
expect(verify.temporalExpr).toContain('implies');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('member parsing - review', () => {
|
||||
it('should parse manual review', () => {
|
||||
const content = `speck Test {
|
||||
input: Nat
|
||||
review: manual
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const review = ast.specks[0].members.find(m => m.type === 'review');
|
||||
expect(review).toBeDefined();
|
||||
if (review?.type === 'review') {
|
||||
expect(review.kind).toBe('manual');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse auto review', () => {
|
||||
const content = `speck Test {
|
||||
input: Nat
|
||||
review: auto
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const review = ast.specks[0].members.find(m => m.type === 'review');
|
||||
if (review?.type === 'review') {
|
||||
expect(review.kind).toBe('auto');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse hybrid review', () => {
|
||||
const content = `speck Test {
|
||||
input: Nat
|
||||
review: hybrid
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const review = ast.specks[0].members.find(m => m.type === 'review');
|
||||
if (review?.type === 'review') {
|
||||
expect(review.kind).toBe('hybrid');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('member parsing - derives and satisfies', () => {
|
||||
it('should parse derives from with via rationale', () => {
|
||||
const content = `speck Test {
|
||||
input: Nat
|
||||
derives from ParentSpeck via "specialization"
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const derives = ast.specks[0].members.find(m => m.type === 'derives');
|
||||
expect(derives).toBeDefined();
|
||||
if (derives?.type === 'derives') {
|
||||
expect(derives.from).toBe('ParentSpeck');
|
||||
expect(derives.via).toBe('specialization');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse derives from without via', () => {
|
||||
const content = `speck Test {
|
||||
input: Nat
|
||||
derives from BaseComponent
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const derives = ast.specks[0].members.find(m => m.type === 'derives');
|
||||
if (derives?.type === 'derives') {
|
||||
expect(derives.from).toBe('BaseComponent');
|
||||
expect(derives.via).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse satisfies with clause', () => {
|
||||
const content = `speck Test {
|
||||
input: Nat
|
||||
satisfies REQ-01 clause "3.1.2"
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const satisfies = ast.specks[0].members.find(m => m.type === 'satisfies');
|
||||
expect(satisfies).toBeDefined();
|
||||
if (satisfies?.type === 'satisfies') {
|
||||
expect(satisfies.requirement).toBe('REQ-01');
|
||||
expect(satisfies.clause).toBe('3.1.2');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse satisfies without clause', () => {
|
||||
const content = `speck Test {
|
||||
input: Nat
|
||||
satisfies RESILIENCE-REQ-02
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const satisfies = ast.specks[0].members.find(m => m.type === 'satisfies');
|
||||
if (satisfies?.type === 'satisfies') {
|
||||
expect(satisfies.requirement).toBe('RESILIENCE-REQ-02');
|
||||
expect(satisfies.clause).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('member parsing - author', () => {
|
||||
it('should parse author with name and email', () => {
|
||||
const content = `speck Test {
|
||||
input: Nat
|
||||
author: "Scott Scoble" <"scott@scoble.me">
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const author = ast.specks[0].members.find(m => m.type === 'author');
|
||||
expect(author).toBeDefined();
|
||||
if (author?.type === 'author') {
|
||||
expect(author.name).toBe('Scott Scoble');
|
||||
expect(author.email).toBe('scott@scoble.me');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('member parsing - source', () => {
|
||||
it('should parse source with kind and ref', () => {
|
||||
const content = `speck Test {
|
||||
input: Nat
|
||||
source: architecture_review ref "AR-2024-0215"
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const source = ast.specks[0].members.find(m => m.type === 'source');
|
||||
expect(source).toBeDefined();
|
||||
if (source?.type === 'source') {
|
||||
expect(source.kind).toBe('architecture_review');
|
||||
expect(source.ref).toBe('AR-2024-0215');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse source without ref', () => {
|
||||
const content = `speck Test {
|
||||
input: Nat
|
||||
source: meeting
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const source = ast.specks[0].members.find(m => m.type === 'source');
|
||||
if (source?.type === 'source') {
|
||||
expect(source.kind).toBe('meeting');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('member parsing - events', () => {
|
||||
it('should parse event definitions', () => {
|
||||
const content = `speck Test {
|
||||
input: Nat
|
||||
event RetryAttempted {
|
||||
attemptNumber: Nat,
|
||||
delay: Real
|
||||
}
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const event = ast.specks[0].members.find(m => m.type === 'event');
|
||||
expect(event).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('member parsing - bom', () => {
|
||||
it('should parse bom declaration', () => {
|
||||
const content = `speck Test {
|
||||
input: Nat
|
||||
bom {
|
||||
compiler: "speckl-compile" version "0.2.0"
|
||||
license: "MIT"
|
||||
}
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const bom = ast.specks[0].members.find(m => m.type === 'bom');
|
||||
expect(bom).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world examples', () => {
|
||||
it('should parse RetryHandler-style speck with full annotations', () => {
|
||||
const content = `speck RetryHandler {
|
||||
bom {
|
||||
compiler: "speckl-compile" version "0.2.0"
|
||||
solver: "z3" version "4.12.5"
|
||||
}
|
||||
provenance {
|
||||
design_decision: "ADR-0021"
|
||||
}
|
||||
derives from FaultHandler via "specialization"
|
||||
satisfies RESILIENCE-REQ-01 clause "3.1.2"
|
||||
author: "Scott Scoble" <"scott@scoble.me">
|
||||
source: architecture_review ref "AR-2024-0215"
|
||||
review: manual
|
||||
input: { operation: Operation, maxRetries: Nat }
|
||||
output: { success: Bool }
|
||||
constraint: maxRetries <= 10
|
||||
constraint: maxRetries >= 0
|
||||
verify: always(implies(attempt > 1, delay > 0))
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
expect(ast.specks).toHaveLength(1);
|
||||
const speck = ast.specks[0];
|
||||
expect(speck.name).toBe('RetryHandler');
|
||||
|
||||
// Should parse all member types
|
||||
const types = speck.members.map(m => m.type);
|
||||
expect(types).toContain('bom');
|
||||
expect(types).toContain('provenance');
|
||||
expect(types).toContain('derives');
|
||||
expect(types).toContain('satisfies');
|
||||
expect(types).toContain('author');
|
||||
expect(types).toContain('source');
|
||||
expect(types).toContain('review');
|
||||
expect(types).toContain('input');
|
||||
expect(types).toContain('output');
|
||||
expect(types).toContain('constraint');
|
||||
expect(types).toContain('verify');
|
||||
});
|
||||
});
|
||||
});
|
||||
5
compiler/dist/parser.js
vendored
5
compiler/dist/parser.js
vendored
|
|
@ -47,7 +47,7 @@ export function parseSpeckContent(content, filePath) {
|
|||
}
|
||||
function parseSpeck(lines, startIndex) {
|
||||
// Parse speck header: speck <Name> {
|
||||
const headerMatch = lines[startIndex].match(/^speck\s+(\w+)\s*{/);
|
||||
const headerMatch = lines[startIndex].match(/^speck\s+([\w-]+)\s*{/);
|
||||
if (!headerMatch) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -112,6 +112,9 @@ function parseMember(line) {
|
|||
return parseEvent(line);
|
||||
}
|
||||
// provenance {
|
||||
if (line.startsWith('provenance ')) {
|
||||
return { type: 'provenance', clauses: [] };
|
||||
}
|
||||
// review:
|
||||
if (line.startsWith('review:')) {
|
||||
const kind = line.substring(7).trim();
|
||||
|
|
|
|||
2
compiler/dist/parser.js.map
vendored
2
compiler/dist/parser.js.map
vendored
File diff suppressed because one or more lines are too long
|
|
@ -1,10 +1,11 @@
|
|||
{
|
||||
"name": "speckl-compile",
|
||||
"version": "0.2.0",
|
||||
"description": "SpeckDL compiler - SpeckDL → Z3 → DST → WASM with provenance and BOM",
|
||||
"version": "0.3.1",
|
||||
"description": "SpeckDL compiler + reference validator - SpeckDL → Z3 → DST → WASM with provenance and BOM",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"speckl-compile": "./dist/index.js"
|
||||
"speckl-compile": "./dist/index.js",
|
||||
"speckl-validate": "./dist/validator.js"
|
||||
},
|
||||
"files": [
|
||||
"dist/"
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ export function parseSpeckContent(content: string, filePath?: string): AST {
|
|||
|
||||
function parseSpeck(lines: string[], startIndex: number): SpeckNode | null {
|
||||
// Parse speck header: speck <Name> {
|
||||
const headerMatch = lines[startIndex].match(/^speck\s+(\w+)\s*{/);
|
||||
const headerMatch = lines[startIndex].match(/^speck\s+([\w-]+)\s*{/);
|
||||
if (!headerMatch) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -266,6 +266,9 @@ function parseMember(line: string): MemberNode | null {
|
|||
}
|
||||
|
||||
// provenance {
|
||||
if (line.startsWith('provenance ')) {
|
||||
return { type: 'provenance', clauses: [] };
|
||||
}
|
||||
|
||||
// review:
|
||||
if (line.startsWith('review:')) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue