@skillstech/thunderlang 0.1.6
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/CHANGELOG.md +375 -0
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/dist/core.cjs +6768 -0
- package/dist/index.cjs +9308 -0
- package/intent-graph.schema.json +687 -0
- package/package.json +84 -0
- package/src/ai-core.mjs +67 -0
- package/src/ai-events.mjs +56 -0
- package/src/ai.mjs +324 -0
- package/src/arch.mjs +55 -0
- package/src/atlas.mjs +118 -0
- package/src/changes.mjs +74 -0
- package/src/classification.mjs +36 -0
- package/src/cli.mjs +1534 -0
- package/src/codegen.mjs +214 -0
- package/src/compile.mjs +142 -0
- package/src/comprehension.mjs +130 -0
- package/src/conflict.mjs +0 -0
- package/src/core.d.ts +99 -0
- package/src/core.mjs +92 -0
- package/src/data-schema.mjs +137 -0
- package/src/decision.mjs +38 -0
- package/src/distributed.mjs +48 -0
- package/src/draft.mjs +101 -0
- package/src/drift.mjs +177 -0
- package/src/emit.mjs +519 -0
- package/src/exporters.mjs +245 -0
- package/src/expr.mjs +245 -0
- package/src/fable.mjs +110 -0
- package/src/focus.mjs +151 -0
- package/src/format.mjs +55 -0
- package/src/governance.mjs +100 -0
- package/src/graph-source.mjs +292 -0
- package/src/guard.mjs +105 -0
- package/src/guardian.mjs +98 -0
- package/src/hash.mjs +89 -0
- package/src/importers.mjs +194 -0
- package/src/index.d.ts +725 -0
- package/src/index.mjs +185 -0
- package/src/intellisense.mjs +210 -0
- package/src/intent-atlas.mjs +77 -0
- package/src/intent-graph.mjs +346 -0
- package/src/intent-ir.mjs +144 -0
- package/src/intent-schema.mjs +215 -0
- package/src/ledger.mjs +109 -0
- package/src/lifecycle.mjs +56 -0
- package/src/lift.mjs +693 -0
- package/src/lsp.mjs +152 -0
- package/src/mcp.mjs +158 -0
- package/src/migrate.mjs +118 -0
- package/src/outcome.mjs +93 -0
- package/src/parse.mjs +733 -0
- package/src/patch.mjs +364 -0
- package/src/privacy.mjs +61 -0
- package/src/proof-schema.mjs +129 -0
- package/src/report.mjs +84 -0
- package/src/runtime.mjs +96 -0
- package/src/sarif.mjs +88 -0
- package/src/scan-queries.mjs +97 -0
- package/src/scan.mjs +87 -0
- package/src/security.mjs +73 -0
- package/src/select.mjs +80 -0
- package/src/semantic-diff.mjs +125 -0
- package/src/simulate.mjs +106 -0
- package/src/style.mjs +250 -0
- package/src/sync.mjs +103 -0
- package/src/testing.mjs +59 -0
- package/src/twelve-factor.mjs +173 -0
- package/src/verify-diff.mjs +105 -0
- package/src/xml.mjs +87 -0
- package/syntaxes/intent.tmLanguage.json +55 -0
package/src/lsp.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// A minimal Language Server for ThunderLang (LSP over stdio). Wraps the compiler's existing
|
|
2
|
+
// diagnostics + IntelliSense so ANY LSP-capable editor (VS Code, Neovim, Helix, ...) gets
|
|
3
|
+
// live ThunderLang intelligence: diagnostics on open/change, keyword/type completions, and
|
|
4
|
+
// hover docs for semantic types and note lenses. Deterministic; no AI. `intent lsp` starts it.
|
|
5
|
+
|
|
6
|
+
import { parseIntent } from './parse.mjs';
|
|
7
|
+
import { semanticDiagnostics } from './emit.mjs';
|
|
8
|
+
import { getCompletions, getHover } from './intellisense.mjs';
|
|
9
|
+
import { COMPILER_VERSION } from './emit.mjs';
|
|
10
|
+
|
|
11
|
+
// LSP CompletionItemKind numbers for the kinds IntelliSense returns.
|
|
12
|
+
const COMPLETION_KIND = { keyword: 14, semantic_type: 7, field: 5, snippet: 15, value: 12, block: 14 };
|
|
13
|
+
// LSP DiagnosticSeverity: 1 error, 2 warning, 3 info, 4 hint.
|
|
14
|
+
const SEVERITY = { error: 1, warning: 2, info: 3 };
|
|
15
|
+
|
|
16
|
+
// Diagnostics don't carry a source span, so anchor them best-effort: to the line containing
|
|
17
|
+
// the quoted subject in the message, else the mission line, else line 0.
|
|
18
|
+
function anchorLine(message, lines) {
|
|
19
|
+
const q = String(message || '').match(/"([^"]+)"/);
|
|
20
|
+
if (q) { const i = lines.findIndex((l) => l.includes(q[1])); if (i >= 0) return i; }
|
|
21
|
+
const m = lines.findIndex((l) => /^mission\b/.test(l.trim()));
|
|
22
|
+
return m >= 0 ? m : 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toLspDiagnostic(d, lines) {
|
|
26
|
+
const line = anchorLine(d.message, lines);
|
|
27
|
+
const text = lines[line] || '';
|
|
28
|
+
return {
|
|
29
|
+
range: { start: { line, character: 0 }, end: { line, character: Math.max(1, text.length) } },
|
|
30
|
+
severity: SEVERITY[d.level] || 3,
|
|
31
|
+
code: d.code,
|
|
32
|
+
source: 'thunderlang',
|
|
33
|
+
message: d.message + (d.why ? `\n\n${d.why}` : ''),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toLspCompletion(item) {
|
|
38
|
+
const insert = item.insertText || item.label;
|
|
39
|
+
return {
|
|
40
|
+
label: item.label,
|
|
41
|
+
kind: COMPLETION_KIND[item.kind] || 1,
|
|
42
|
+
detail: item.detail || undefined,
|
|
43
|
+
insertText: insert,
|
|
44
|
+
insertTextFormat: /\$\{|\$\d/.test(insert) ? 2 : 1, // 2 = snippet, 1 = plaintext
|
|
45
|
+
sortText: item.sortText || undefined,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toLspHover(out) {
|
|
50
|
+
const h = out && out.hover;
|
|
51
|
+
if (!h) return null;
|
|
52
|
+
const parts = [h.title ? `**${h.title}**` : null, h.description || null];
|
|
53
|
+
if (Array.isArray(h.examples) && h.examples.length) parts.push('```\n' + h.examples.join('\n') + '\n```');
|
|
54
|
+
return { contents: { kind: 'markdown', value: parts.filter(Boolean).join('\n\n') } };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Start the language server. Reads LSP JSON-RPC (Content-Length framed) from `readable` and
|
|
59
|
+
* writes responses to `writable`. Testable: pass in-memory streams. Returns nothing.
|
|
60
|
+
*/
|
|
61
|
+
export function startLspServer({ readable = process.stdin, writable = process.stdout } = {}) {
|
|
62
|
+
const docs = new Map(); // uri -> text
|
|
63
|
+
let buffer = Buffer.alloc(0);
|
|
64
|
+
|
|
65
|
+
const send = (msg) => {
|
|
66
|
+
const payload = Buffer.from(JSON.stringify(msg), 'utf8');
|
|
67
|
+
writable.write(`Content-Length: ${payload.length}\r\n\r\n`);
|
|
68
|
+
writable.write(payload);
|
|
69
|
+
};
|
|
70
|
+
const respond = (id, result) => send({ jsonrpc: '2.0', id, result });
|
|
71
|
+
const notify = (method, params) => send({ jsonrpc: '2.0', method, params });
|
|
72
|
+
|
|
73
|
+
const publish = (uri) => {
|
|
74
|
+
const lines = (docs.get(uri) || '').split('\n');
|
|
75
|
+
let diags;
|
|
76
|
+
try { diags = semanticDiagnostics(parseIntent(docs.get(uri) || '')); } catch { diags = []; }
|
|
77
|
+
notify('textDocument/publishDiagnostics', { uri, diagnostics: diags.map((d) => toLspDiagnostic(d, lines)) });
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handle = (msg) => {
|
|
81
|
+
switch (msg.method) {
|
|
82
|
+
case 'initialize':
|
|
83
|
+
respond(msg.id, {
|
|
84
|
+
capabilities: {
|
|
85
|
+
textDocumentSync: 1, // full document sync
|
|
86
|
+
completionProvider: { triggerCharacters: [' '] },
|
|
87
|
+
hoverProvider: true,
|
|
88
|
+
},
|
|
89
|
+
serverInfo: { name: 'thunderlang-lsp', version: COMPILER_VERSION },
|
|
90
|
+
});
|
|
91
|
+
break;
|
|
92
|
+
case 'initialized':
|
|
93
|
+
break;
|
|
94
|
+
case 'shutdown':
|
|
95
|
+
respond(msg.id, null);
|
|
96
|
+
break;
|
|
97
|
+
case 'exit':
|
|
98
|
+
process.exit(0);
|
|
99
|
+
break;
|
|
100
|
+
case 'textDocument/didOpen':
|
|
101
|
+
docs.set(msg.params.textDocument.uri, msg.params.textDocument.text || '');
|
|
102
|
+
publish(msg.params.textDocument.uri);
|
|
103
|
+
break;
|
|
104
|
+
case 'textDocument/didChange': {
|
|
105
|
+
const changes = msg.params.contentChanges || [];
|
|
106
|
+
if (changes.length) docs.set(msg.params.textDocument.uri, changes[changes.length - 1].text || '');
|
|
107
|
+
publish(msg.params.textDocument.uri);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case 'textDocument/didClose':
|
|
111
|
+
docs.delete(msg.params.textDocument.uri);
|
|
112
|
+
break;
|
|
113
|
+
case 'textDocument/completion': {
|
|
114
|
+
const text = docs.get(msg.params.textDocument.uri) || '';
|
|
115
|
+
const p = msg.params.position;
|
|
116
|
+
let items = [];
|
|
117
|
+
try { items = (getCompletions(text, { line: p.line + 1, column: p.character + 1 }).items || []); } catch { items = []; }
|
|
118
|
+
respond(msg.id, items.map(toLspCompletion));
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case 'textDocument/hover': {
|
|
122
|
+
const text = docs.get(msg.params.textDocument.uri) || '';
|
|
123
|
+
const p = msg.params.position;
|
|
124
|
+
let hover = null;
|
|
125
|
+
try { hover = toLspHover(getHover(text, { line: p.line + 1, column: p.character + 1 })); } catch { hover = null; }
|
|
126
|
+
respond(msg.id, hover);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
default:
|
|
130
|
+
if (msg.id != null) respond(msg.id, null); // unknown request -> empty result, keep alive
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
readable.on('data', (chunk) => {
|
|
135
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
136
|
+
for (;;) {
|
|
137
|
+
const headerEnd = buffer.indexOf('\r\n\r\n');
|
|
138
|
+
if (headerEnd < 0) break;
|
|
139
|
+
const header = buffer.slice(0, headerEnd).toString('ascii');
|
|
140
|
+
const m = header.match(/Content-Length:\s*(\d+)/i);
|
|
141
|
+
if (!m) { buffer = buffer.slice(headerEnd + 4); continue; }
|
|
142
|
+
const len = Number(m[1]);
|
|
143
|
+
const start = headerEnd + 4;
|
|
144
|
+
if (buffer.length < start + len) break; // wait for the rest of the body
|
|
145
|
+
const body = buffer.slice(start, start + len).toString('utf8');
|
|
146
|
+
buffer = buffer.slice(start + len);
|
|
147
|
+
let msg;
|
|
148
|
+
try { msg = JSON.parse(body); } catch { continue; }
|
|
149
|
+
try { handle(msg); } catch { /* one bad message must not kill the server */ }
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// ThunderLang MCP server , makes ThunderLang a first-class tool for AI coding agents (Claude
|
|
2
|
+
// Code, Cursor, ...). It speaks the Model Context Protocol over stdio (newline-delimited
|
|
3
|
+
// JSON-RPC 2.0) and exposes the deterministic capabilities an agent needs to author intent,
|
|
4
|
+
// check its own output, and , the keystone , verify a proposed code change against the intent
|
|
5
|
+
// before it ships. No AI runs here; every tool is the pure, deterministic compiler.
|
|
6
|
+
//
|
|
7
|
+
// Start it with `intent mcp`. Point an MCP client at that command.
|
|
8
|
+
|
|
9
|
+
import { parseIntent } from './parse.mjs';
|
|
10
|
+
import { semanticDiagnostics, COMPILER_VERSION } from './emit.mjs';
|
|
11
|
+
import { verifyDiff } from './verify-diff.mjs';
|
|
12
|
+
import { liftSource, SUPPORTED_LANGUAGES } from './lift.mjs';
|
|
13
|
+
import { runTests } from './testing.mjs';
|
|
14
|
+
import { evaluateDecision } from './runtime.mjs';
|
|
15
|
+
import { buildIntentGraph } from './intent-graph.mjs';
|
|
16
|
+
import { draftIntent } from './draft.mjs';
|
|
17
|
+
import { ALL_DIAGNOSTICS } from './intent-schema.mjs';
|
|
18
|
+
|
|
19
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
20
|
+
const str = { type: 'string' };
|
|
21
|
+
|
|
22
|
+
// Each tool is a pure function of its arguments. `run` returns a JSON-able value or a string.
|
|
23
|
+
const TOOLS = [
|
|
24
|
+
{
|
|
25
|
+
name: 'intent_check',
|
|
26
|
+
description: 'Run ThunderLang semantic diagnostics on .intent source. Returns the diagnostics (codes, severity, messages) , the deterministic checks that catch missing verification, secrets on the bus, contradictions, and more.',
|
|
27
|
+
inputSchema: { type: 'object', required: ['source'], properties: { source: { ...str, description: 'the .intent source' } } },
|
|
28
|
+
run: ({ source }) => {
|
|
29
|
+
const diagnostics = semanticDiagnostics(parseIntent(String(source)));
|
|
30
|
+
return { ok: !diagnostics.some((d) => d.level === 'error'), count: diagnostics.length, diagnostics };
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'intent_verify_diff',
|
|
35
|
+
description: 'THE AI-LOOP GATE. Prove, deterministically, which of an intent\'s guarantees and never-rules a code change upholds or breaks. Returns verdict PASS or BLOCK. Blocks on regressions (a claim that held on `before` and broke on `after`) and guardrail hits (an added line pushing a protected secret into a log/response). Call this on your own proposed change before shipping it.',
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: 'object', required: ['intent', 'after'],
|
|
38
|
+
properties: {
|
|
39
|
+
intent: { ...str, description: 'the .intent source (the contract)' },
|
|
40
|
+
after: { ...str, description: 'the proposed/changed code' },
|
|
41
|
+
before: { ...str, description: 'the code before the change (optional; enables regression detection)' },
|
|
42
|
+
language: { ...str, description: `source language (default typescript). One of: ${SUPPORTED_LANGUAGES.join(', ')}` },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
run: ({ intent, after, before = null, language = 'typescript' }) => verifyDiff(String(intent), { before: before == null ? null : String(before), after: String(after), language }),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'intent_draft',
|
|
49
|
+
description: 'Turn a STRUCTURED brief into a rigorous, canonically-formatted ThunderLang draft plus a review checklist of what a human must still fill in (unverified guarantees, unguarded secrets, missing goal). Use this after distilling a user request into structured fields; the draft is a proposal for human approval, never verified.',
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: 'object', required: ['brief'],
|
|
52
|
+
properties: { brief: { type: 'object', description: 'fields: mission, goal, actor, problem, guarantees[], neverRules[], inputs[{name,type}], outputs[], decisions[]' } },
|
|
53
|
+
},
|
|
54
|
+
run: ({ brief }) => draftIntent(brief || {}),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'intent_lift',
|
|
58
|
+
description: 'Lift source code into an inferred, humble ThunderLang draft (never claims verified). Covers the top languages. Use it to bootstrap intent from code you already have.',
|
|
59
|
+
inputSchema: {
|
|
60
|
+
type: 'object', required: ['source'],
|
|
61
|
+
properties: { source: { ...str, description: 'the source code' }, language: { ...str, description: `source language (default typescript). One of: ${SUPPORTED_LANGUAGES.join(', ')}` } },
|
|
62
|
+
},
|
|
63
|
+
run: ({ source, language = 'typescript' }) => {
|
|
64
|
+
const r = liftSource(String(source), { language });
|
|
65
|
+
return r.ok ? { ok: true, intent: r.intentText, summary: r.summary, diagnostics: r.diagnostics } : { ok: false, error: r.error };
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'intent_run',
|
|
70
|
+
description: 'Evaluate a decision in .intent source against concrete inputs (FIRST-hit, with a per-rule trace). Deterministic , the intent itself decides.',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object', required: ['source', 'inputs'],
|
|
73
|
+
properties: { source: str, inputs: { type: 'object', description: 'the input values' }, decision: { ...str, description: 'decision name (default: the first declared)' } },
|
|
74
|
+
},
|
|
75
|
+
run: ({ source, inputs = {}, decision }) => {
|
|
76
|
+
const ast = parseIntent(String(source));
|
|
77
|
+
const d = decision ? (ast.decisions || []).find((x) => x.name === decision) : (ast.decisions || [])[0];
|
|
78
|
+
if (!d) return { error: decision ? `no decision "${decision}"` : 'no decision declared' };
|
|
79
|
+
return evaluateDecision(d, inputs);
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'intent_test',
|
|
84
|
+
description: 'Run the in-file test blocks (case/scenario) in .intent source through the deterministic runtime. The spec proves itself.',
|
|
85
|
+
inputSchema: { type: 'object', required: ['source'], properties: { source: str } },
|
|
86
|
+
run: ({ source }) => runTests(parseIntent(String(source))),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'intent_graph',
|
|
90
|
+
description: 'Build the canonical Intent Graph (intent-graph-v1) from .intent source , the typed nodes + relationships other tools reason over.',
|
|
91
|
+
inputSchema: { type: 'object', required: ['source'], properties: { source: str } },
|
|
92
|
+
run: ({ source }) => buildIntentGraph(parseIntent(String(source))),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'intent_explain',
|
|
96
|
+
description: 'Explain a diagnostic code (area, severity, what it blocks). Accepts any code the compiler emits.',
|
|
97
|
+
inputSchema: { type: 'object', required: ['code'], properties: { code: { ...str, description: 'e.g. IL-SEC-001' } } },
|
|
98
|
+
run: ({ code }) => ALL_DIAGNOSTICS.find((r) => r.ruleId.toLowerCase() === String(code).toLowerCase()) || { code, found: false },
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
export const MCP_TOOLS = TOOLS.map((t) => t.name);
|
|
103
|
+
|
|
104
|
+
/** Start the ThunderLang MCP server over the given streams (default: process stdio). */
|
|
105
|
+
export function startMcpServer({ readable = process.stdin, writable = process.stdout } = {}) {
|
|
106
|
+
const send = (msg) => writable.write(`${JSON.stringify(msg)}\n`);
|
|
107
|
+
const respond = (id, result) => send({ jsonrpc: '2.0', id, result });
|
|
108
|
+
const fail = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
109
|
+
|
|
110
|
+
function handle(msg) {
|
|
111
|
+
const { id, method, params } = msg || {};
|
|
112
|
+
switch (method) {
|
|
113
|
+
case 'initialize':
|
|
114
|
+
respond(id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: { name: 'thunderlang', version: COMPILER_VERSION } });
|
|
115
|
+
break;
|
|
116
|
+
case 'notifications/initialized':
|
|
117
|
+
case 'initialized':
|
|
118
|
+
case 'notifications/cancelled':
|
|
119
|
+
break; // notifications , no response
|
|
120
|
+
case 'ping':
|
|
121
|
+
respond(id, {});
|
|
122
|
+
break;
|
|
123
|
+
case 'tools/list':
|
|
124
|
+
respond(id, { tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })) });
|
|
125
|
+
break;
|
|
126
|
+
case 'tools/call': {
|
|
127
|
+
const tool = TOOLS.find((t) => t.name === params?.name);
|
|
128
|
+
if (!tool) { fail(id, -32602, `Unknown tool: ${params?.name}`); break; }
|
|
129
|
+
try {
|
|
130
|
+
const result = tool.run(params.arguments || {});
|
|
131
|
+
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
|
132
|
+
respond(id, { content: [{ type: 'text', text }] });
|
|
133
|
+
} catch (e) {
|
|
134
|
+
respond(id, { content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true });
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
default:
|
|
139
|
+
if (id != null) fail(id, -32601, `Method not found: ${method}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let buffer = '';
|
|
144
|
+
readable.on('data', (chunk) => {
|
|
145
|
+
buffer += chunk.toString('utf8');
|
|
146
|
+
let nl;
|
|
147
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
148
|
+
const line = buffer.slice(0, nl).trim();
|
|
149
|
+
buffer = buffer.slice(nl + 1);
|
|
150
|
+
if (!line) continue;
|
|
151
|
+
let msg;
|
|
152
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
153
|
+
handle(msg);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
if (typeof readable.resume === 'function') readable.resume();
|
|
157
|
+
return { handle }; // exposed for in-process testing
|
|
158
|
+
}
|
package/src/migrate.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Schema migrations for the Intent Graph. As `intent-graph-v1` evolves, consumers (OT, RM,
|
|
2
|
+
// ST) hold persisted graphs; when the schema bumps, those old graphs must upgrade
|
|
3
|
+
// deterministically rather than break. This is the upgrade path: an ordered chain of pure
|
|
4
|
+
// migration steps + declarative builders so a future v1 -> v2 is a one-liner, plus a
|
|
5
|
+
// baseline that normalizes pre-versioned (v0) graphs into a well-formed v1.
|
|
6
|
+
|
|
7
|
+
import { SCHEMA_VERSION, NODE_TYPES, RELATIONSHIP_TYPES } from './intent-schema.mjs';
|
|
8
|
+
|
|
9
|
+
export const MIGRATION_SCHEMA = 'intent-migration-v1';
|
|
10
|
+
|
|
11
|
+
// The ordered version chain. Every known schema version, oldest first. A graph with no (or
|
|
12
|
+
// unrecognized) `schema` is treated as the oldest, `intent-graph-v0` (pre-versioned).
|
|
13
|
+
export const SCHEMA_CHAIN = ['intent-graph-v0', 'intent-graph-v1'];
|
|
14
|
+
|
|
15
|
+
// The standard v1 node fields, with their defaults, for backfilling legacy nodes.
|
|
16
|
+
const NODE_DEFAULTS = {
|
|
17
|
+
title: null, description: null, status: 'draft', owner: null,
|
|
18
|
+
classification: null, confidence: null, source: null, tags: [],
|
|
19
|
+
createdTime: null, updatedTime: null,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** The schema version of a graph (defaults unversioned/unknown graphs to the oldest). */
|
|
23
|
+
export function graphVersion(graph) {
|
|
24
|
+
const v = graph && graph.schema;
|
|
25
|
+
return SCHEMA_CHAIN.includes(v) ? v : SCHEMA_CHAIN[0];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── Declarative migration builders , compose these into a step's `migrate` function. ──
|
|
29
|
+
|
|
30
|
+
/** Rename every node of `fromType` to `toType`. */
|
|
31
|
+
export const renameNodeType = (fromType, toType) => (g) => ({
|
|
32
|
+
...g, nodes: (g.nodes || []).map((n) => (n.type === fromType ? { ...n, type: toType } : n)),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** Rename every relationship of `fromType` to `toType`. */
|
|
36
|
+
export const renameRelationshipType = (fromType, toType) => (g) => ({
|
|
37
|
+
...g, relationships: (g.relationships || []).map((r) => (r.type === fromType ? { ...r, type: toType } : r)),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** Backfill a field on every node (only where currently missing/undefined). */
|
|
41
|
+
export const backfillNodeField = (field, value) => (g) => ({
|
|
42
|
+
...g, nodes: (g.nodes || []).map((n) => (n[field] === undefined ? { ...n, [field]: (Array.isArray(value) ? [...value] : value) } : n)),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Drop a field from every node. */
|
|
46
|
+
export const dropNodeField = (field) => (g) => ({
|
|
47
|
+
...g, nodes: (g.nodes || []).map((n) => { const c = { ...n }; delete c[field]; return c; }),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** Run several step-transforms left to right, then stamp the target schema version. */
|
|
51
|
+
const pipe = (toVersion, ...fns) => (g) => ({ ...fns.reduce((acc, fn) => fn(acc), g), schema: toVersion });
|
|
52
|
+
|
|
53
|
+
// ── The baseline migration: v0 (pre-versioned) -> v1 ──
|
|
54
|
+
// Normalizes a legacy graph into a well-formed v1: stamps the schema and backfills every
|
|
55
|
+
// standard v1 node field so downstream code can rely on the shape.
|
|
56
|
+
function migrateV0toV1(g) {
|
|
57
|
+
const nodes = (g.nodes || []).map((n) => {
|
|
58
|
+
const out = { ...n };
|
|
59
|
+
for (const [k, v] of Object.entries(NODE_DEFAULTS)) if (out[k] === undefined) out[k] = Array.isArray(v) ? [...v] : v;
|
|
60
|
+
return out;
|
|
61
|
+
});
|
|
62
|
+
const relationships = (g.relationships || []).map((r) => ({ from: r.from, type: r.type, to: r.to, ...(r.name !== undefined ? { name: r.name } : {}), ...(r.within !== undefined ? { within: r.within } : {}) }));
|
|
63
|
+
return { ...g, schema: 'intent-graph-v1', nodes, relationships };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The registry: exactly one step per adjacent version pair. To add v2, append
|
|
67
|
+
// { from:'intent-graph-v1', to:'intent-graph-v2', description, migrate: pipe('intent-graph-v2', ...builders) }.
|
|
68
|
+
export const MIGRATIONS = [
|
|
69
|
+
{ from: 'intent-graph-v0', to: 'intent-graph-v1', description: 'Stamp schema version and backfill the standard node fields.', migrate: migrateV0toV1 },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Migrate a graph forward to a target schema version (default: the latest). Applies each
|
|
74
|
+
* registered step in chain order. Deterministic and pure , the input graph is not mutated.
|
|
75
|
+
* @returns {{schema, from, to, migrated, applied, graph}}
|
|
76
|
+
*/
|
|
77
|
+
export function migrateGraph(graph, { to = SCHEMA_VERSION } = {}) {
|
|
78
|
+
if (!SCHEMA_CHAIN.includes(to)) throw new Error(`migrateGraph: unknown target schema "${to}"`);
|
|
79
|
+
const from = graphVersion(graph);
|
|
80
|
+
const targetIdx = SCHEMA_CHAIN.indexOf(to);
|
|
81
|
+
if (SCHEMA_CHAIN.indexOf(from) > targetIdx) throw new Error(`migrateGraph: cannot downgrade from "${from}" to "${to}"`);
|
|
82
|
+
|
|
83
|
+
let cur = from;
|
|
84
|
+
let g = graph;
|
|
85
|
+
const applied = [];
|
|
86
|
+
let guard = 0;
|
|
87
|
+
while (cur !== to) {
|
|
88
|
+
const step = MIGRATIONS.find((m) => m.from === cur);
|
|
89
|
+
if (!step) throw new Error(`migrateGraph: no migration registered from "${cur}"`);
|
|
90
|
+
g = step.migrate(g);
|
|
91
|
+
applied.push({ from: step.from, to: step.to, description: step.description });
|
|
92
|
+
cur = step.to;
|
|
93
|
+
if (++guard > SCHEMA_CHAIN.length + 1) throw new Error('migrateGraph: migration loop detected');
|
|
94
|
+
}
|
|
95
|
+
return { schema: MIGRATION_SCHEMA, from, to, migrated: applied.length > 0, applied, graph: g };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Validate a graph against the canonical vocabulary: every node has an id + canonical type,
|
|
100
|
+
* every relationship has a canonical type and non-dangling endpoints. Returns issues (never
|
|
101
|
+
* throws). Use after a migration to confirm the result is well-formed.
|
|
102
|
+
*/
|
|
103
|
+
export function validateGraph(graph) {
|
|
104
|
+
const issues = [];
|
|
105
|
+
const nodeTypes = new Set(NODE_TYPES);
|
|
106
|
+
const relTypes = new Set(RELATIONSHIP_TYPES);
|
|
107
|
+
const ids = new Set((graph.nodes || []).map((n) => n.id));
|
|
108
|
+
for (const n of graph.nodes || []) {
|
|
109
|
+
if (!n.id) issues.push({ level: 'error', code: 'MIG-001', message: 'node has no id' });
|
|
110
|
+
if (!nodeTypes.has(n.type)) issues.push({ level: 'error', code: 'MIG-002', message: `unknown node type "${n.type}"`, id: n.id });
|
|
111
|
+
}
|
|
112
|
+
for (const r of graph.relationships || []) {
|
|
113
|
+
if (!relTypes.has(r.type)) issues.push({ level: 'error', code: 'MIG-003', message: `unknown relationship type "${r.type}"` });
|
|
114
|
+
if (!(ids.has(r.from) || String(r.from).startsWith('phase.'))) issues.push({ level: 'error', code: 'MIG-004', message: `dangling relationship from "${r.from}"` });
|
|
115
|
+
if (!(ids.has(r.to) || String(r.to).startsWith('phase.'))) issues.push({ level: 'error', code: 'MIG-005', message: `dangling relationship to "${r.to}"` });
|
|
116
|
+
}
|
|
117
|
+
return { schema: 'intent-graph-validation-v1', version: graphVersion(graph), valid: issues.length === 0, issues };
|
|
118
|
+
}
|
package/src/outcome.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Outcome contracts , an executable commitment about a result. An `outcome_contract` binds
|
|
2
|
+
// an outcome to a metric, a baseline, and a target, with a direction (higher/lower is
|
|
3
|
+
// better) and a measurement window. Unlike a wish, a contract can be EVALUATED: given the
|
|
4
|
+
// actual measured value (from a delivery `result`, or supplied directly), the runtime says
|
|
5
|
+
// whether the commitment was met. Deterministic and pure , no AI.
|
|
6
|
+
|
|
7
|
+
export const OUTCOME_SCHEMA = 'intent-outcome-v1';
|
|
8
|
+
|
|
9
|
+
// Parse a target/baseline/actual value into a comparable number, keeping the unit for
|
|
10
|
+
// display. Handles "60%", "48 percent", "$1,000", "2.5s", plain numbers.
|
|
11
|
+
export function parseValue(raw) {
|
|
12
|
+
if (raw == null) return { value: null, unit: null, raw };
|
|
13
|
+
const s = String(raw).trim();
|
|
14
|
+
const m = s.match(/-?\d[\d,]*\.?\d*/);
|
|
15
|
+
if (!m) return { value: null, unit: null, raw: s };
|
|
16
|
+
const value = Number(m[0].replace(/,/g, ''));
|
|
17
|
+
const unit = s.includes('%') || /\bpercent\b/i.test(s) ? '%' : (s.replace(m[0], '').trim() || null);
|
|
18
|
+
return { value, unit, raw: s };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Evaluate an outcome contract against an actual measured value. Deterministic.
|
|
23
|
+
* "Better" is set by direction: higher (default) means met when actual >= target; lower
|
|
24
|
+
* means met when actual <= target. Returns null-ish `met` when the contract or actual is
|
|
25
|
+
* not numerically comparable (so the caller can flag "cannot evaluate").
|
|
26
|
+
* @returns {{schema, contract, target, actual, baseline, direction, met, improvement, comparable}}
|
|
27
|
+
*/
|
|
28
|
+
export function evaluateOutcomeContract(contract, actual) {
|
|
29
|
+
const t = parseValue(contract.target);
|
|
30
|
+
const a = parseValue(actual);
|
|
31
|
+
const b = parseValue(contract.baseline);
|
|
32
|
+
const dir = contract.direction === 'lower' ? 'lower' : 'higher';
|
|
33
|
+
const comparable = t.value != null && a.value != null;
|
|
34
|
+
const met = comparable ? (dir === 'lower' ? a.value <= t.value : a.value >= t.value) : null;
|
|
35
|
+
const improvement = (b.value != null && a.value != null) ? Number((a.value - b.value).toFixed(6)) : null;
|
|
36
|
+
return {
|
|
37
|
+
schema: OUTCOME_SCHEMA,
|
|
38
|
+
contract: contract.name,
|
|
39
|
+
target: t.raw, actual: a.raw ?? null, baseline: b.raw ?? null,
|
|
40
|
+
direction: dir, met, improvement, comparable,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Evaluate every outcome contract in an AST against the delivery `result` that measures the
|
|
46
|
+
* same outcome (or shares the metric). Contracts with no matching result are reported
|
|
47
|
+
* `pending` (comparable=false). This turns "did we achieve the outcome?" into a check.
|
|
48
|
+
*/
|
|
49
|
+
export function evaluateOutcomes(ast) {
|
|
50
|
+
const results = ast.results || [];
|
|
51
|
+
const evaluations = (ast.outcomeContracts || []).map((c) => {
|
|
52
|
+
// Match a delivery result by the outcome it measures, else by metric name.
|
|
53
|
+
const r = results.find((x) => x.measures && c.outcome && x.measures === c.outcome)
|
|
54
|
+
|| results.find((x) => x.metric && c.metric && x.metric === c.metric);
|
|
55
|
+
const evalResult = evaluateOutcomeContract(c, r ? r.value : null);
|
|
56
|
+
return { ...evalResult, matchedResult: r ? r.name : null, status: !evalResult.comparable ? 'pending' : (evalResult.met ? 'met' : 'missed') };
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
schema: OUTCOME_SCHEMA,
|
|
60
|
+
total: evaluations.length,
|
|
61
|
+
met: evaluations.filter((e) => e.status === 'met').length,
|
|
62
|
+
missed: evaluations.filter((e) => e.status === 'missed').length,
|
|
63
|
+
pending: evaluations.filter((e) => e.status === 'pending').length,
|
|
64
|
+
evaluations,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Authoring-time checks on declared outcome contracts (IL-OC-001..004). */
|
|
69
|
+
export function outcomeDiagnostics(ast) {
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const c of ast.outcomeContracts || []) {
|
|
72
|
+
const t = parseValue(c.target);
|
|
73
|
+
const b = parseValue(c.baseline);
|
|
74
|
+
if (!c.target) out.push({ code: 'IL-OC-001', contract: c.name, severity: 'blocker',
|
|
75
|
+
message: `Outcome contract "${c.name}" has no target, so it cannot be evaluated.` });
|
|
76
|
+
if (!c.metric) out.push({ code: 'IL-OC-002', contract: c.name, severity: 'warning',
|
|
77
|
+
message: `Outcome contract "${c.name}" names no metric.` });
|
|
78
|
+
if (!c.window) out.push({ code: 'IL-OC-003', contract: c.name, severity: 'blocker',
|
|
79
|
+
message: `Outcome contract "${c.name}" has no measurement window.` });
|
|
80
|
+
if (t.value != null && b.value != null) {
|
|
81
|
+
const better = c.direction === 'lower' ? t.value < b.value : t.value > b.value;
|
|
82
|
+
if (!better) out.push({ code: 'IL-OC-004', contract: c.name, severity: 'warning',
|
|
83
|
+
message: `Outcome contract "${c.name}" target (${t.raw}) is not better than its baseline (${b.raw}) for a "${c.direction}" goal.` });
|
|
84
|
+
}
|
|
85
|
+
// A target with no guardrail can be "achieved" by regressing something else (Goodhart's law).
|
|
86
|
+
if (c.target && !(c.guardrails || []).length) out.push({ code: 'IL-OC-005', contract: c.name, severity: 'warning',
|
|
87
|
+
message: `Outcome contract "${c.name}" has a target but no guardrails, so the target could be met by regressing something else.` });
|
|
88
|
+
// Never let a metric move read as causation , the attribution must be stated honestly.
|
|
89
|
+
if (c.target && (!c.attribution || c.attribution === 'unknown')) out.push({ code: 'IL-OC-006', contract: c.name, severity: 'info',
|
|
90
|
+
message: `Outcome contract "${c.name}" has no attribution , a metric moving after release is correlation, not proof this feature caused it.` });
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|