@skillstech/thunderlang 0.1.7 → 0.3.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/CHANGELOG.md +53 -0
- package/dist/core.cjs +240 -20
- package/dist/index.cjs +241 -21
- package/intent-graph.schema.json +227 -4
- package/package.json +1 -1
- package/src/cli.mjs +632 -23
- package/src/conformance.mjs +35 -0
- package/src/coverage.mjs +56 -0
- package/src/emit.mjs +3 -1
- package/src/expr.mjs +11 -7
- package/src/intent-graph.mjs +29 -0
- package/src/lift.mjs +79 -3
- package/src/mutate.mjs +46 -0
- package/src/parse.mjs +82 -3
- package/src/property.mjs +108 -0
- package/src/target-cs.mjs +98 -0
- package/src/target-java.mjs +87 -0
- package/src/target-py.mjs +90 -0
- package/src/target-ts.mjs +66 -0
- package/src/target-util.mjs +95 -0
- package/src/testing.mjs +1 -1
package/src/cli.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// thunder check <file> parse + semantic diagnostics (exit 1 on error)
|
|
9
9
|
// thunder graph <file> [--out .intent] contract-graph.json + architecture-graph.json
|
|
10
|
-
// intent proof <file> [--out .intent] .
|
|
10
|
+
// intent proof <file> [--out .intent] .thunder-proof.json
|
|
11
11
|
// intent build <file> [--out .intent] [--no-ai] all artifacts + docs + mermaid + testplan
|
|
12
12
|
//
|
|
13
13
|
// --no-ai is the default and only mode today; the flag is accepted for forward-compatibility.
|
|
@@ -39,18 +39,49 @@ import { liftSource, liftAll, liftRepo, languageForFile } from './lift.mjs';
|
|
|
39
39
|
import { approveIntent, checkDrift, buildDriftHandoff } from './drift.mjs';
|
|
40
40
|
import { buildMissionIndex } from './atlas.mjs';
|
|
41
41
|
import { parseSelection, regionMetrics, selectCandidate } from './select.mjs';
|
|
42
|
-
import { buildIntentGraph } from './intent-graph.mjs';
|
|
42
|
+
import { buildIntentGraph, safeGraph } from './intent-graph.mjs';
|
|
43
43
|
import { buildAtlas, searchAtlas, expandNode } from './intent-atlas.mjs';
|
|
44
44
|
import { buildFocusGraph, intentBrief, makeScope } from './focus.mjs';
|
|
45
45
|
import { comprehensionLevel, comprehensionReport, LEVELS as COMPREHENSION_LEVELS } from './comprehension.mjs';
|
|
46
46
|
import { twelveFactorReport } from './twelve-factor.mjs';
|
|
47
47
|
import { GENERATORS } from './codegen.mjs';
|
|
48
48
|
import { changeReport } from './changes.mjs';
|
|
49
|
-
import { execSync } from 'node:child_process';
|
|
49
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
50
50
|
import { diffGraphs, mergeGraphs } from './semantic-diff.mjs';
|
|
51
51
|
import { applyWaivers, governanceDiagnostics } from './governance.mjs';
|
|
52
52
|
import { exportIntent, EXPORT_FORMATS } from './exporters.mjs';
|
|
53
53
|
import { evaluateDecision, simulateLifecycle } from './runtime.mjs';
|
|
54
|
+
import { runProperties } from './property.mjs';
|
|
55
|
+
import { runMutations } from './mutate.mjs';
|
|
56
|
+
import { buildConformance } from './conformance.mjs';
|
|
57
|
+
import { semanticCoverage } from './coverage.mjs';
|
|
58
|
+
import { runTypescriptTarget } from './target-ts.mjs';
|
|
59
|
+
import { runPythonTarget, pythonAvailable } from './target-py.mjs';
|
|
60
|
+
import { runCSharpTarget, csharpAvailable } from './target-cs.mjs';
|
|
61
|
+
import { runJavaTarget, javaAvailable } from './target-java.mjs';
|
|
62
|
+
|
|
63
|
+
// The live-target registry: one entry per canonical target that can be compiled + executed.
|
|
64
|
+
// `available()` is a (cached) toolchain probe; TypeScript/JS runs in-process so it is always live.
|
|
65
|
+
const LIVE_TARGETS = [
|
|
66
|
+
{ key: 'typescript', run: runTypescriptTarget, available: () => true },
|
|
67
|
+
{ key: 'python', run: runPythonTarget, available: pythonAvailable },
|
|
68
|
+
{ key: 'csharp', run: runCSharpTarget, available: csharpAvailable },
|
|
69
|
+
{ key: 'java', run: runJavaTarget, available: javaAvailable },
|
|
70
|
+
];
|
|
71
|
+
const LIVE_BY_KEY = new Map(LIVE_TARGETS.map((t) => [t.key, t]));
|
|
72
|
+
LIVE_BY_KEY.set('javascript', LIVE_BY_KEY.get('typescript')); // js is the same in-process runner
|
|
73
|
+
// All canonical targets available to run right now (used by --all-targets).
|
|
74
|
+
const availableLiveTargets = () => LIVE_TARGETS.filter((t) => t.available());
|
|
75
|
+
const RUNNABLE_TARGETS = new Set(['typescript', 'ts', 'javascript', 'js', 'python', 'py', 'csharp', 'cs', 'c#', 'java']);
|
|
76
|
+
// Map an alias to its canonical target key + the adapter that executes it.
|
|
77
|
+
const TARGET_ALIASES = { ts: 'typescript', js: 'javascript', py: 'python', cs: 'csharp', 'c#': 'csharp' };
|
|
78
|
+
const canonicalTarget = (t) => TARGET_ALIASES[String(t).toLowerCase()] || String(t).toLowerCase();
|
|
79
|
+
// Execute a live target. Returns { "Test / case": actual } or null if the target can't run
|
|
80
|
+
// (e.g. the runtime/SDK is not installed). Unknown targets return null.
|
|
81
|
+
function runLiveTarget(ast, target) {
|
|
82
|
+
const entry = LIVE_BY_KEY.get(canonicalTarget(target));
|
|
83
|
+
return entry ? entry.run(ast) : null;
|
|
84
|
+
}
|
|
54
85
|
import { importIntent, importReport, detectFormat, IMPORT_FORMATS } from './importers.mjs';
|
|
55
86
|
import { runTests } from './testing.mjs';
|
|
56
87
|
import { evaluateOutcomes } from './outcome.mjs';
|
|
@@ -67,7 +98,13 @@ import {
|
|
|
67
98
|
import { parseEventLog, serializeEventLog, recordEvent, timeline } from './ai-events.mjs';
|
|
68
99
|
|
|
69
100
|
// Recursively collect supported source files, skipping vendored / build dirs.
|
|
70
|
-
|
|
101
|
+
// Kept in sync with lift.mjs languageForFile(): every language the lift adapters support,
|
|
102
|
+
// so repo-mode discovery matches the advertised multi-language lift surface.
|
|
103
|
+
const LIFT_EXTS = [
|
|
104
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.rs', '.pl', '.pm', '.t',
|
|
105
|
+
'.py', '.pyi', '.java', '.cs', '.go', '.cpp', '.cc', '.cxx', '.hpp', '.hh', '.c', '.h',
|
|
106
|
+
'.php', '.rb', '.kt', '.kts', '.scala', '.sc', '.ex', '.exs',
|
|
107
|
+
];
|
|
71
108
|
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.next', 'build', '.intent', 'coverage', '.vercel']);
|
|
72
109
|
// ThunderLang source files. `.thunder` is the canonical public extension; `.tl` is an
|
|
73
110
|
// accepted shorthand; `.intent` stays supported so legacy IntentLang sources keep working.
|
|
@@ -98,6 +135,77 @@ function collectIntents(root, acc = []) {
|
|
|
98
135
|
return acc;
|
|
99
136
|
}
|
|
100
137
|
|
|
138
|
+
// Resolve every guarantee/never obligation against the SPECIFIC test that verifies it.
|
|
139
|
+
// Shared by `thunder test --contracts` and `thunder prove` so both agree per-claim.
|
|
140
|
+
// States: verified (proven by a passing named test), failed (its test fails), declared
|
|
141
|
+
// (a verification is named/classified but not runnable in-file), unverified (nothing).
|
|
142
|
+
function resolveObligations(ast) {
|
|
143
|
+
const t = runTests(ast);
|
|
144
|
+
const testPass = new Map();
|
|
145
|
+
for (const res of (t.results || [])) {
|
|
146
|
+
const cur = testPass.get(res.target);
|
|
147
|
+
testPass.set(res.target, cur === undefined ? !!res.pass : cur && !!res.pass);
|
|
148
|
+
}
|
|
149
|
+
const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
150
|
+
const matchTest = (verifyText) => {
|
|
151
|
+
const v = norm(verifyText); if (!v) return null;
|
|
152
|
+
for (const [name, pass] of testPass) { const n = norm(name); if (n && (n === v || n.includes(v) || v.includes(n))) return { name, pass }; }
|
|
153
|
+
return null;
|
|
154
|
+
};
|
|
155
|
+
const build = (kind, o) => {
|
|
156
|
+
const verify = o.verify || [];
|
|
157
|
+
const kinds = [...new Set((o.verifications || []).map((v) => v.kind).filter((k) => k && k !== 'unclassified'))];
|
|
158
|
+
let status, provenBy = null;
|
|
159
|
+
if (!verify.length) status = 'unverified';
|
|
160
|
+
else {
|
|
161
|
+
let m = null;
|
|
162
|
+
for (const vt of verify) { const x = matchTest(vt); if (x) { m = x; if (!x.pass) break; } }
|
|
163
|
+
if (m) { status = m.pass ? 'verified' : 'failed'; provenBy = m.name; } else status = 'declared';
|
|
164
|
+
}
|
|
165
|
+
return { kind, id: o.id, text: o.statement, status, verify, kinds, provenBy };
|
|
166
|
+
};
|
|
167
|
+
const obligations = [
|
|
168
|
+
...ast.guarantees.map((g) => build('guarantee', g)),
|
|
169
|
+
...ast.neverRules.map((n) => build('prohibition', n)),
|
|
170
|
+
];
|
|
171
|
+
const count = (s) => obligations.filter((o) => o.status === s).length;
|
|
172
|
+
return { obligations, tests: t, verified: count('verified'), declared: count('declared'), unverified: count('unverified'), failed: count('failed') };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Proof freshness: a proof is valid only for a specific (intent, implementation, dependencies,
|
|
176
|
+
// compiler, environment) tuple. These helpers capture that tuple so `verify` can mark a proof STALE.
|
|
177
|
+
const gitCmd = (c) => { try { return execSync(`git ${c}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim() || null; } catch { return null; } };
|
|
178
|
+
const LOCKFILES = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock', 'poetry.lock', 'Pipfile.lock', 'Cargo.lock', 'go.sum', 'Gemfile.lock', 'composer.lock'];
|
|
179
|
+
function findLockfile(startDir) {
|
|
180
|
+
let dir = startDir || '.';
|
|
181
|
+
for (let i = 0; i < 6; i++) {
|
|
182
|
+
for (const lf of LOCKFILES) { const p = join(dir, lf); if (existsSync(p)) return p; }
|
|
183
|
+
const parent = dirname(dir); if (parent === dir) break; dir = parent;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
function freshnessFor(file, proof, envName) {
|
|
188
|
+
const dir = dirname(file) || '.';
|
|
189
|
+
const lock = findLockfile(dir);
|
|
190
|
+
return {
|
|
191
|
+
intentHash: proof.sourceHash,
|
|
192
|
+
compilerVersion: proof.compilerVersion,
|
|
193
|
+
implementation: gitCmd('rev-parse HEAD'),
|
|
194
|
+
dependencies: lock ? { file: basename(lock), hash: sha256(readFileSync(lock, 'utf8')) } : null,
|
|
195
|
+
environment: envName || null,
|
|
196
|
+
generatedAt: proof.generatedAt,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// Given a proof's recorded freshness, recompute the current tuple and list what has drifted.
|
|
200
|
+
function stalenessReasons(freshness, srcDir) {
|
|
201
|
+
const reasons = [];
|
|
202
|
+
if (!freshness) return reasons;
|
|
203
|
+
if (freshness.implementation) { const now = gitCmd('rev-parse HEAD'); if (now && now !== freshness.implementation) reasons.push(`implementation moved: proof at ${freshness.implementation.slice(0, 7)}, now ${now.slice(0, 7)}`); }
|
|
204
|
+
if (freshness.dependencies) { const lock = findLockfile(srcDir); const now = lock ? sha256(readFileSync(lock, 'utf8')) : null; if (now && now !== freshness.dependencies.hash) reasons.push(`dependency lockfile changed (${freshness.dependencies.file})`); }
|
|
205
|
+
if (freshness.compilerVersion && freshness.compilerVersion !== COMPILER_VERSION) reasons.push(`compiler upgraded: proof ${freshness.compilerVersion}, now ${COMPILER_VERSION}`);
|
|
206
|
+
return reasons;
|
|
207
|
+
}
|
|
208
|
+
|
|
101
209
|
function parseArgs(argv) {
|
|
102
210
|
const args = { _: [], out: '.intent', noAi: false };
|
|
103
211
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -129,6 +237,19 @@ function parseArgs(argv) {
|
|
|
129
237
|
else if (a === '--check') args.check = true;
|
|
130
238
|
else if (a === '--schema') args.schema = true;
|
|
131
239
|
else if (a === '--all') args.all = true;
|
|
240
|
+
else if (a === '--contracts') args.contracts = true;
|
|
241
|
+
else if (a === '--strict') args.strict = true;
|
|
242
|
+
else if (a === '--changed') args.changed = true;
|
|
243
|
+
else if (a === '--properties') args.properties = true;
|
|
244
|
+
else if (a === '--scenarios') args.scenarios = true;
|
|
245
|
+
else if (a === '--mutate') args.mutate = true;
|
|
246
|
+
else if (a === '--safe') args.safe = true;
|
|
247
|
+
else if (a === '--evals') args.evals = true;
|
|
248
|
+
else if (a === '--coverage') args.coverage = true;
|
|
249
|
+
else if (a === '--run') args.run = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
250
|
+
else if (a === '--results') args.results = argv[++i];
|
|
251
|
+
else if (a === '--cases') args.cases = argv[++i];
|
|
252
|
+
else if (a === '--seed') args.seed = argv[++i];
|
|
132
253
|
else if (a === '--ir') args.ir = argv[++i];
|
|
133
254
|
else if (a === '--subject') args.subject = argv[++i];
|
|
134
255
|
else if (a === '--lens') args.lens = argv[++i];
|
|
@@ -139,6 +260,8 @@ function parseArgs(argv) {
|
|
|
139
260
|
else if (a === '--learning') args.learning = true;
|
|
140
261
|
else if (a === '--governed') args.governed = true;
|
|
141
262
|
else if (a === '--target') args.target = argv[++i];
|
|
263
|
+
else if (a === '--all-targets') args.allTargets = true;
|
|
264
|
+
else if (a === '--env') args.env = argv[++i];
|
|
142
265
|
else if (a === '--brief') args.brief = argv[++i];
|
|
143
266
|
else if (a === '--after') args.after = argv[++i];
|
|
144
267
|
else if (a === '--before') args.before = argv[++i];
|
|
@@ -202,12 +325,25 @@ function printDiagnostics(diags) {
|
|
|
202
325
|
return errors;
|
|
203
326
|
}
|
|
204
327
|
|
|
205
|
-
const HELP = `thunder , the
|
|
328
|
+
const HELP = `thunder , the ThunderLang compiler + engine (deterministic, no AI required)
|
|
329
|
+
|
|
330
|
+
usage: thunder <command> <file> [options]
|
|
331
|
+
thunder mission <Name> <command> run any command on a mission by name
|
|
206
332
|
|
|
207
|
-
|
|
333
|
+
Everyday
|
|
334
|
+
new [Name] scaffold a runnable starter mission (Name.thunder) [alias: init]
|
|
335
|
+
mission <Name> [cmd] resolve a mission by name and run a command on it (list | <Name> | <Name> <cmd>)
|
|
336
|
+
check <file|dir> parse + lint + explainable diagnostics
|
|
337
|
+
run <file> --inputs '<json>' execute the decision(s) deterministically
|
|
338
|
+
test <file> [--contracts | --properties | --scenarios | --mutate | --evals | --changed | --coverage] [--strict]
|
|
339
|
+
test <file> --target typescript|python|csharp|java run the tests against the EXECUTED generated code
|
|
340
|
+
test <file> --all-targets run the tests against every AVAILABLE target in one pass
|
|
341
|
+
prove <file> emit an intent-proof-v1 artifact (honest verdicts + freshness)
|
|
342
|
+
verify <proof.json> [src] re-check a proof; reports STALE when impl/deps/compiler moved
|
|
343
|
+
build <file> generate docs, contract graph, test plan, targets
|
|
208
344
|
|
|
209
345
|
Author & check
|
|
210
|
-
init [Name] scaffold a runnable starter mission (Name.
|
|
346
|
+
init [Name] scaffold a runnable starter mission (Name.thunder)
|
|
211
347
|
draft --brief <json|-> scaffold a rigorous intent draft + gap checklist from a brief
|
|
212
348
|
check <file|dir> [--json|--format sarif] diagnostics for one file, or gate a whole dir
|
|
213
349
|
report [dir] [--json] repo-wide intent health: severity + area counts, coverage
|
|
@@ -226,11 +362,12 @@ Author & check
|
|
|
226
362
|
edit <file> [--edits <json|->] [--set-goal ..] [--add-guarantee ..] [--write] structural edits, comments kept
|
|
227
363
|
lsp start the Language Server (LSP over stdio, for editors)
|
|
228
364
|
mcp start the MCP server (for AI coding agents; stdio)
|
|
229
|
-
build <file> docs, contract graph, test plan, and .
|
|
230
|
-
graph <file>
|
|
231
|
-
proof <file> the .
|
|
365
|
+
build <file> docs, contract graph, test plan, and .thunder-proof.json
|
|
366
|
+
graph <file> [--safe] the canonical Intent Graph (intent-graph-v1); --safe = display-safe on stdout
|
|
367
|
+
proof <file> the .thunder-proof.json artifact
|
|
232
368
|
proof --schema emit the canonical proof envelope JSON Schema (intent-proof-v1)
|
|
233
369
|
verify <proof.json> [src] confirm a proof is well-formed and still matches its source
|
|
370
|
+
conform <file> [--targets a,b] [--run typescript,python,csharp,java | --all-targets] [--results <json>] run the same cases against every target (conformance matrix)
|
|
234
371
|
schema emit the canonical graph schema + diagnostic catalog
|
|
235
372
|
explain <IL-CODE> explain a diagnostic code (area, severity, what it blocks)
|
|
236
373
|
rules [--json] list the whole canonical diagnostic catalog
|
|
@@ -249,7 +386,7 @@ Execute (no AI, no generated code)
|
|
|
249
386
|
Interop
|
|
250
387
|
export <file> --format <dmn|bpmn|smv|jsonschema|openapi|tokens|mermaid|css|playwright> render to a standard format
|
|
251
388
|
import <file> [--format dmn|bpmn] [--json] lift DMN/BPMN into intent
|
|
252
|
-
source <file|graph.json> regenerate .
|
|
389
|
+
source <file|graph.json> regenerate .thunder from a graph
|
|
253
390
|
migrate <graph.json> [--to <version>] upgrade a persisted graph
|
|
254
391
|
validate <graph.json> [--json] check a graph is canonical (anti-fork)
|
|
255
392
|
|
|
@@ -292,7 +429,7 @@ function main() {
|
|
|
292
429
|
console.log(JSON.stringify(intentProofJsonSchema(), null, 2));
|
|
293
430
|
return;
|
|
294
431
|
}
|
|
295
|
-
// Verify a .
|
|
432
|
+
// Verify a .thunder-proof.json against its source: the source hash still matches (no
|
|
296
433
|
// drift / tampering) and the proof's claims re-derive from the source.
|
|
297
434
|
if (cmd === 'verify') {
|
|
298
435
|
const proofPath = args._[0];
|
|
@@ -312,16 +449,22 @@ function main() {
|
|
|
312
449
|
// Structural gate: the envelope must be a well-formed intent-proof-v1 document first.
|
|
313
450
|
const structure = validateProof(proof);
|
|
314
451
|
const valid = structure.valid && hashMatch && semanticMatch && guaranteesMatch && neverMatch;
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
452
|
+
// Freshness: even if the intent still matches, the proof is STALE if the implementation,
|
|
453
|
+
// dependencies, or compiler moved since it was generated. A stale proof must not read green.
|
|
454
|
+
const staleReasons = valid ? stalenessReasons(proof.freshness, dirname(srcPath) || '.') : [];
|
|
455
|
+
const stale = staleReasons.length > 0;
|
|
456
|
+
const status = !valid ? 'FAILED' : stale ? 'STALE' : 'VALID';
|
|
457
|
+
const result = { schema: 'intent-verify-v1', proof: proofPath, source: srcPath, valid, stale, status, staleReasons, checks: { wellFormed: structure.valid, hashMatch, semanticMatch, guaranteesMatch, neverMatch }, structureErrors: structure.errors };
|
|
458
|
+
if (args.json) { console.log(JSON.stringify(result, null, 2)); process.exit(valid && !stale ? 0 : 1); return; }
|
|
459
|
+
console.log(`thunder verify ${basename(proofPath)}: ${status} (source ${basename(srcPath)})`);
|
|
318
460
|
if (!structure.valid) { console.log(` X proof is not a well-formed intent-proof-v1 document:`); for (const e of structure.errors) console.log(` ${e.path || '(root)'}: ${e.message}`); }
|
|
319
461
|
if (!hashMatch) console.log(' X source hash does not match , the source has changed since the proof was generated (drift or tampering).');
|
|
320
462
|
if (!semanticMatch) console.log(' X the proof claims a different semantic result than the source produces now.');
|
|
321
463
|
if (!guaranteesMatch) console.log(' X guarantee count differs from the proof.');
|
|
322
464
|
if (!neverMatch) console.log(' X never-rule count differs from the proof.');
|
|
323
|
-
if (
|
|
324
|
-
|
|
465
|
+
if (stale) { console.log(' ! proof is STALE , the intent still matches, but the world moved since it was generated:'); for (const r of staleReasons) console.log(` ${r}`); console.log(' regenerate: thunder prove ' + basename(srcPath)); }
|
|
466
|
+
if (valid && !stale) console.log(` ok proof matches source and is fresh (hash + ${(proof.guarantees || []).length} guarantee(s), ${(proof.neverRules || []).length} never-rule(s)).`);
|
|
467
|
+
process.exit(valid && !stale ? 0 : 1);
|
|
325
468
|
return;
|
|
326
469
|
}
|
|
327
470
|
|
|
@@ -571,7 +714,7 @@ function main() {
|
|
|
571
714
|
}
|
|
572
715
|
|
|
573
716
|
// Scaffold a runnable starter mission (deterministic, no AI). `thunder init [Name]`.
|
|
574
|
-
if (cmd === 'init') {
|
|
717
|
+
if (cmd === 'init' || cmd === 'new') {
|
|
575
718
|
const name = stripSourceExt(file || 'Mission');
|
|
576
719
|
const target = join(args.out && args.out !== '.intent' ? args.out : '.', `${name}.thunder`);
|
|
577
720
|
if (existsSync(target) && !args.force) {
|
|
@@ -612,15 +755,61 @@ test Example
|
|
|
612
755
|
`;
|
|
613
756
|
if (target.includes('/')) mkdirSync(dirname(target), { recursive: true });
|
|
614
757
|
writeFileSync(target, starter);
|
|
615
|
-
console.log(`thunder
|
|
758
|
+
console.log(`thunder ${cmd}: wrote ${target}`);
|
|
616
759
|
console.log(` next: thunder check ${target} | thunder run ${target} --inputs '{"age":20}' | thunder test ${target}`);
|
|
617
760
|
return;
|
|
618
761
|
}
|
|
619
762
|
|
|
620
|
-
if (!file && cmd !== 'draft') {
|
|
763
|
+
if (!file && cmd !== 'draft' && cmd !== 'mission' && !(cmd === 'test' && args.changed)) {
|
|
621
764
|
console.error(`thunder ${cmd}: missing a file argument. Run "intent help" for usage.`);
|
|
622
765
|
process.exit(2);
|
|
623
766
|
}
|
|
767
|
+
|
|
768
|
+
// `thunder mission <Name> [<verb> ...]` , noun-first ergonomics: resolve a mission by name
|
|
769
|
+
// anywhere in the project, then run any verb on it without typing a path.
|
|
770
|
+
// `thunder mission list` lists every mission. `thunder mission <Name>` prints a summary.
|
|
771
|
+
if (cmd === 'mission') {
|
|
772
|
+
const scanRoot = process.cwd();
|
|
773
|
+
const parsed = collectIntents(scanRoot)
|
|
774
|
+
.map((f) => { try { return { file: f, ast: parseIntent(readFileSync(f, 'utf8')) }; } catch { return null; } })
|
|
775
|
+
.filter((x) => x && x.ast && x.ast.mission);
|
|
776
|
+
const raw = process.argv.slice(3); // [Name|list, verb?, ...passthrough]
|
|
777
|
+
const nameArg = raw[0];
|
|
778
|
+
|
|
779
|
+
if (!nameArg || nameArg === 'list') {
|
|
780
|
+
if (!parsed.length) { console.log('thunder mission list: no missions found under the current directory.'); return; }
|
|
781
|
+
console.log(`thunder mission list: ${parsed.length} mission(s)`);
|
|
782
|
+
for (const { file: f, ast } of parsed.sort((a, b) => (a.ast.mission || '').localeCompare(b.ast.mission || ''))) {
|
|
783
|
+
const g = (ast.guarantees || []).length, n = (ast.neverRules || []).length, t = (ast.tests || []).length;
|
|
784
|
+
console.log(` ${(ast.mission || '(unnamed)').padEnd(26)} ${relative(scanRoot, f)} (${g} guarantee(s), ${n} never, ${t} test(s))`);
|
|
785
|
+
}
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
790
|
+
const matches = parsed.filter((x) => x.ast.mission === nameArg);
|
|
791
|
+
const hit = matches[0] || parsed.find((x) => norm(x.ast.mission) === norm(nameArg));
|
|
792
|
+
if (!hit) { console.error(`thunder mission: no mission named "${nameArg}". Try: thunder mission list`); process.exit(2); return; }
|
|
793
|
+
if (matches.length > 1) console.error(`thunder mission: ${matches.length} missions named "${nameArg}"; using ${relative(scanRoot, hit.file)}`);
|
|
794
|
+
|
|
795
|
+
const verb = raw[1];
|
|
796
|
+
if (!verb) {
|
|
797
|
+
const a = hit.ast;
|
|
798
|
+
console.log(`mission ${a.mission} (${relative(scanRoot, hit.file)})`);
|
|
799
|
+
if (a.goal) console.log(` goal: ${a.goal}`);
|
|
800
|
+
if ((a.guarantees || []).length) { console.log(' guarantees:'); for (const g of a.guarantees) console.log(` - ${g.statement}`); }
|
|
801
|
+
if ((a.neverRules || []).length) { console.log(' never:'); for (const nr of a.neverRules) console.log(` - ${nr.statement}`); }
|
|
802
|
+
if ((a.targets || []).length) console.log(` targets: ${a.targets.join(', ')}`);
|
|
803
|
+
if ((a.tests || []).length) console.log(` tests: ${a.tests.length}`);
|
|
804
|
+
console.log(` try: thunder mission ${a.mission} check | run | test | prove | build`);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Delegate to `thunder <verb> <file> <passthrough>` in a child so every flag is honored exactly.
|
|
809
|
+
const res = spawnSync(process.execPath, [process.argv[1], verb, hit.file, ...raw.slice(2)], { stdio: 'inherit' });
|
|
810
|
+
process.exit(res.status == null ? 0 : res.status);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
624
813
|
// IntentLift: lift source CODE into inferred .intent drafts (not intent parsing).
|
|
625
814
|
if (cmd === 'lift') {
|
|
626
815
|
// Repo mode: walk a directory, lift each file, emit drafts + a repo summary.
|
|
@@ -663,9 +852,9 @@ test Example
|
|
|
663
852
|
return;
|
|
664
853
|
}
|
|
665
854
|
|
|
666
|
-
// Single-file mode.
|
|
855
|
+
// Single-file mode. Auto-detect language by extension (consistent with --all / repo modes).
|
|
667
856
|
const src = readFileSync(file, 'utf8');
|
|
668
|
-
const res = liftSource(src, { language: args.from ||
|
|
857
|
+
const res = liftSource(src, { language: args.from || languageForFile(file), file: basename(file) });
|
|
669
858
|
if (!res.ok) { console.error(res.error); process.exit(1); }
|
|
670
859
|
if (args.json) { console.log(JSON.stringify(res.summary, null, 2)); return; }
|
|
671
860
|
if (args.out) {
|
|
@@ -957,7 +1146,285 @@ test Example
|
|
|
957
1146
|
|
|
958
1147
|
// Self-verifying intent: run the `test` blocks in a .intent file (decisions + lifecycles).
|
|
959
1148
|
if (cmd === 'test') {
|
|
1149
|
+
// Change-impact selection: run the tests for what a diff actually affects, using the Intent
|
|
1150
|
+
// Graph (not just the modified files). Selects changed intents plus any intent that shares an
|
|
1151
|
+
// event / service / api symbol with a changed one. `thunder test --changed [<range>]`.
|
|
1152
|
+
if (args.changed) {
|
|
1153
|
+
const gitc = (c) => { try { return execSync(`git ${c}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }); } catch { return null; } };
|
|
1154
|
+
const top = gitc('rev-parse --show-toplevel');
|
|
1155
|
+
if (top === null) { console.error('thunder test --changed: not a git repository'); process.exit(2); return; }
|
|
1156
|
+
const root = top.trim();
|
|
1157
|
+
const range = file || '';
|
|
1158
|
+
let base, head;
|
|
1159
|
+
if (range.includes('..')) { [base, head] = range.split('..'); head = head || 'HEAD'; }
|
|
1160
|
+
else if (range) { base = range; head = null; }
|
|
1161
|
+
else { base = 'HEAD'; head = null; }
|
|
1162
|
+
const diffSpec = head ? `${base} ${head}` : base;
|
|
1163
|
+
const changed = (gitc(`diff --name-only ${diffSpec} -- "*.thunder" "*.tl" "*.intent"`) || '').split('\n').map((s) => s.trim()).filter(Boolean);
|
|
1164
|
+
const label = range || `${base}..working-tree`;
|
|
1165
|
+
if (!changed.length) { console.log(`thunder test --changed ${label}: no source files changed`); return; }
|
|
1166
|
+
|
|
1167
|
+
const symbolsOf = (ast) => {
|
|
1168
|
+
const s = new Set();
|
|
1169
|
+
if (!ast) return s;
|
|
1170
|
+
if (ast.mission) s.add(`system:${ast.mission}`);
|
|
1171
|
+
for (const e of ast.events || []) s.add(`event:${e.name}`);
|
|
1172
|
+
for (const sv of ast.services || []) { s.add(`service:${sv.name}`); (sv.publishes || []).forEach((p) => s.add(`event:${p}`)); (sv.consumes || []).forEach((p) => s.add(`event:${p}`)); }
|
|
1173
|
+
for (const ap of ast.apis || []) s.add(`api:${ap.name}`);
|
|
1174
|
+
return s;
|
|
1175
|
+
};
|
|
1176
|
+
const rel = (p) => relative(root, p);
|
|
1177
|
+
const astAt = (relpath) => { const abs = join(root, relpath); if (!existsSync(abs)) return null; try { return parseIntent(readFileSync(abs, 'utf8')); } catch { return null; } };
|
|
1178
|
+
|
|
1179
|
+
// Seed symbols from the changed intents, then find every repo intent that shares one.
|
|
1180
|
+
const changedAsts = new Map(changed.map((p) => [p, astAt(p)]));
|
|
1181
|
+
const seed = new Set();
|
|
1182
|
+
for (const ast of changedAsts.values()) for (const sym of symbolsOf(ast)) seed.add(sym);
|
|
1183
|
+
const selected = new Map();
|
|
1184
|
+
for (const p of changed) selected.set(p, 'changed');
|
|
1185
|
+
for (const abs of collectIntents(root)) {
|
|
1186
|
+
const p = rel(abs);
|
|
1187
|
+
if (selected.has(p)) continue;
|
|
1188
|
+
const shared = [...symbolsOf(parseIntent(readFileSync(abs, 'utf8')))].filter((x) => seed.has(x));
|
|
1189
|
+
if (shared.length) selected.set(p, `impacted via ${shared[0].replace(':', ' ')}`);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
let anyFail = false;
|
|
1193
|
+
const results = [];
|
|
1194
|
+
for (const [p, reason] of selected) {
|
|
1195
|
+
const ast = changedAsts.get(p) || astAt(p);
|
|
1196
|
+
if (!ast) { results.push({ file: p, reason, deleted: true }); continue; }
|
|
1197
|
+
const r = runTests(ast);
|
|
1198
|
+
const res = resolveObligations(ast);
|
|
1199
|
+
const testsOk = r.total === 0 || r.ok;
|
|
1200
|
+
if (!testsOk || res.failed > 0) anyFail = true;
|
|
1201
|
+
results.push({ file: p, reason, tests: { passed: r.passed, total: r.total, ok: testsOk }, unverified: res.unverified, failedClaims: res.failed });
|
|
1202
|
+
}
|
|
1203
|
+
if (args.json) { console.log(JSON.stringify({ schema: 'thunder-changed-v1', range: label, changed, selected: results }, null, 2)); process.exit(anyFail ? 1 : 0); return; }
|
|
1204
|
+
console.log(`thunder test --changed ${label}: ${changed.length} changed, ${selected.size} intent(s) selected`);
|
|
1205
|
+
for (const r of results) {
|
|
1206
|
+
if (r.deleted) { console.log(` , ${r.file} [${r.reason}, deleted]`); continue; }
|
|
1207
|
+
const mark = r.tests.ok && !r.failedClaims ? 'PASS' : 'FAIL';
|
|
1208
|
+
console.log(` ${mark.padEnd(6)} ${r.file} [${r.reason}] tests ${r.tests.passed}/${r.tests.total}${r.unverified ? `, ${r.unverified} unverified` : ''}`);
|
|
1209
|
+
}
|
|
1210
|
+
process.exit(anyFail ? 1 : 0);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
960
1214
|
const ast = parseIntent(readFileSync(file, 'utf8'));
|
|
1215
|
+
|
|
1216
|
+
// Property-based testing: generate many cases for each `property`, evaluate the decision,
|
|
1217
|
+
// assert the `expect` clauses, and shrink failures. `thunder test <file> --properties`.
|
|
1218
|
+
if (args.properties) {
|
|
1219
|
+
const cases = Number.isFinite(Number(args.cases)) && Number(args.cases) > 0 ? Math.min(Math.floor(Number(args.cases)), 100000) : 100;
|
|
1220
|
+
const seed = Number.isFinite(Number(args.seed)) ? Math.floor(Number(args.seed)) : 424242;
|
|
1221
|
+
const rep = runProperties(ast, { cases, seed });
|
|
1222
|
+
const bad = rep.passed !== rep.total;
|
|
1223
|
+
if (args.json) { console.log(JSON.stringify(rep, null, 2)); process.exit(bad ? 1 : 0); return; }
|
|
1224
|
+
if (!rep.total) { console.log(`thunder test ${basename(file)} --properties: no property blocks found.`); return; }
|
|
1225
|
+
console.log(`thunder test ${basename(file)} --properties: ${rep.total} propert${rep.total === 1 ? 'y' : 'ies'}, ${rep.passed} passed`);
|
|
1226
|
+
for (const r of rep.results) {
|
|
1227
|
+
if (r.error) { console.log(` FAIL ${r.property} , ${r.error}`); continue; }
|
|
1228
|
+
if (r.ok) { console.log(` PASS ${r.property} (${r.cases} cases, seed ${r.seed})`); continue; }
|
|
1229
|
+
const f = r.failure;
|
|
1230
|
+
const inputs = Object.entries(f.inputs).map(([k, v]) => `${k}=${v}`).join(', ');
|
|
1231
|
+
console.log(` FAIL ${r.property} (seed ${r.seed})`);
|
|
1232
|
+
console.log(` smallest failure: ${inputs} -> ${f.expect} (got ${f.actual})`);
|
|
1233
|
+
}
|
|
1234
|
+
process.exit(bad ? 1 : 0);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// Scenario tests: workflow-level given/when/then/never. Deterministically flags a scenario
|
|
1239
|
+
// that both expects and prohibits the same outcome; otherwise DECLARED (needs runtime evidence).
|
|
1240
|
+
if (args.scenarios) {
|
|
1241
|
+
const scenarios = ast.scenarios || [];
|
|
1242
|
+
const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
1243
|
+
const results = scenarios.map((sc) => {
|
|
1244
|
+
const neverSet = new Set(sc.never.map(norm));
|
|
1245
|
+
const positives = [...sc.then, ...sc.eventually.flatMap((e) => e.clauses)];
|
|
1246
|
+
const contradictions = positives.filter((p) => neverSet.has(norm(p)));
|
|
1247
|
+
return { scenario: sc.name, status: contradictions.length ? 'failed' : 'declared', given: sc.given.length, then: positives.length, never: sc.never.length, contradictions };
|
|
1248
|
+
});
|
|
1249
|
+
const failed = results.filter((r) => r.status === 'failed').length;
|
|
1250
|
+
const rep = { schema: 'thunder-scenarios-v1', mission: ast.mission, file: basename(file), total: results.length, failed, results };
|
|
1251
|
+
if (args.json) { console.log(JSON.stringify(rep, null, 2)); process.exit(failed ? 1 : 0); return; }
|
|
1252
|
+
if (!rep.total) { console.log(`thunder test ${basename(file)} --scenarios: no scenario blocks found.`); return; }
|
|
1253
|
+
console.log(`thunder test ${basename(file)} --scenarios: ${rep.total} scenario(s), ${rep.total - failed} consistent${failed ? `, ${failed} contradictory` : ''}`);
|
|
1254
|
+
for (const r of results) {
|
|
1255
|
+
if (r.status === 'failed') console.log(` FAIL ${r.scenario} , contradiction: ${r.contradictions.map((c) => `"${c}"`).join(', ')} both expected and prohibited`);
|
|
1256
|
+
else console.log(` DECLARED ${r.scenario} (${r.given} given, ${r.then} then, ${r.never} never) , needs runtime evidence`);
|
|
1257
|
+
}
|
|
1258
|
+
process.exit(failed ? 1 : 0);
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// All-targets mode: run the tests against EVERY target whose toolchain is available, in one
|
|
1263
|
+
// pass, and report each target's pass count side by side. `thunder test <file> --all-targets`.
|
|
1264
|
+
if (args.allTargets) {
|
|
1265
|
+
const gradeCases = (actual) => {
|
|
1266
|
+
const cs = [];
|
|
1267
|
+
for (const t of ast.tests || []) {
|
|
1268
|
+
if (!(ast.decisions || []).some((d) => d.name === t.name)) continue;
|
|
1269
|
+
for (const c of t.cases || []) {
|
|
1270
|
+
const key = `${t.name} / ${c.name || 'case'}`;
|
|
1271
|
+
const act = actual[key];
|
|
1272
|
+
cs.push({ key, expected: c.expect, actual: act, pass: c.expect == null || String(act) === String(c.expect) });
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
return cs;
|
|
1276
|
+
};
|
|
1277
|
+
const report = LIVE_TARGETS.map((t) => {
|
|
1278
|
+
const actual = t.available() ? t.run(ast) : null;
|
|
1279
|
+
if (actual === null) return { target: t.key, status: 'skipped', reason: `${t.key} toolchain not available` };
|
|
1280
|
+
const cases = gradeCases(actual);
|
|
1281
|
+
const passed = cases.filter((c) => c.pass).length;
|
|
1282
|
+
return { target: t.key, status: passed === cases.length ? 'pass' : 'fail', total: cases.length, passed, cases };
|
|
1283
|
+
});
|
|
1284
|
+
const bad = report.some((r) => r.status === 'fail');
|
|
1285
|
+
if (args.json) { console.log(JSON.stringify({ schema: 'thunder-all-targets-v1', mission: ast.mission, file: basename(file), targets: report }, null, 2)); process.exit(bad ? 1 : 0); return; }
|
|
1286
|
+
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
1287
|
+
const ran = report.filter((r) => r.status !== 'skipped');
|
|
1288
|
+
console.log(`thunder test ${basename(file)} --all-targets: ${ran.length}/${report.length} target(s) executed`);
|
|
1289
|
+
for (const r of report) {
|
|
1290
|
+
if (r.status === 'skipped') { console.log(` SKIP ${cap(r.target).padEnd(12)} (toolchain not available)`); continue; }
|
|
1291
|
+
console.log(` ${r.status === 'pass' ? 'PASS' : 'FAIL'} ${cap(r.target).padEnd(12)} ${r.passed}/${r.total} passed (executed generated code)`);
|
|
1292
|
+
for (const c of r.cases) if (!c.pass) console.log(` FAIL ${c.key} , expected ${c.expected}, got ${c.actual}`);
|
|
1293
|
+
}
|
|
1294
|
+
process.exit(bad ? 1 : 0);
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// Target mode: run the tests against the GENERATED TypeScript (executed for real), not the
|
|
1299
|
+
// semantic engine. Proves the generated implementation is faithful to the intent.
|
|
1300
|
+
// `thunder test <file> --target typescript`.
|
|
1301
|
+
if (args.target && RUNNABLE_TARGETS.has(String(args.target).toLowerCase())) {
|
|
1302
|
+
const targetKey = canonicalTarget(args.target);
|
|
1303
|
+
const actual = runLiveTarget(ast, targetKey);
|
|
1304
|
+
// Live target could not run (e.g. python3 not installed) , skip cleanly, don't fail.
|
|
1305
|
+
if (actual === null) {
|
|
1306
|
+
if (args.json) { console.log(JSON.stringify({ schema: 'thunder-target-v1', target: targetKey, skipped: true, reason: `${targetKey} runtime unavailable` }, null, 2)); return; }
|
|
1307
|
+
console.log(`thunder test ${basename(file)} --target ${targetKey}: skipped (the ${targetKey} runtime is not available on this machine).`);
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
const cases = [];
|
|
1311
|
+
for (const t of ast.tests || []) {
|
|
1312
|
+
const dec = (ast.decisions || []).find((d) => d.name === t.name);
|
|
1313
|
+
if (!dec) continue;
|
|
1314
|
+
for (const c of t.cases || []) {
|
|
1315
|
+
const key = `${t.name} / ${c.name || 'case'}`;
|
|
1316
|
+
const act = actual[key];
|
|
1317
|
+
cases.push({ key, expected: c.expect, actual: act, pass: c.expect == null || String(act) === String(c.expect) });
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
const passed = cases.filter((c) => c.pass).length;
|
|
1321
|
+
const bad = passed !== cases.length;
|
|
1322
|
+
if (args.json) { console.log(JSON.stringify({ schema: 'thunder-target-v1', target: targetKey, total: cases.length, passed, cases }, null, 2)); process.exit(bad ? 1 : 0); return; }
|
|
1323
|
+
if (!cases.length) { console.log(`thunder test ${basename(file)} --target ${targetKey}: no decision test cases to run.`); return; }
|
|
1324
|
+
console.log(`thunder test ${basename(file)} --target ${targetKey}: ${passed}/${cases.length} passed (executed generated code)`);
|
|
1325
|
+
for (const c of cases) console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.key}${c.pass ? '' : ` , expected ${c.expected}, got ${c.actual}`}`);
|
|
1326
|
+
process.exit(bad ? 1 : 0);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Semantic coverage: which goals, decision rules, guarantees, prohibitions, scenarios, and
|
|
1331
|
+
// targets are actually exercised , meaning-level, not lines. `thunder test <file> --coverage`.
|
|
1332
|
+
if (args.coverage) {
|
|
1333
|
+
const rep = semanticCoverage(ast);
|
|
1334
|
+
const bad = args.strict && rep.unverified.length > 0;
|
|
1335
|
+
if (args.json) { console.log(JSON.stringify(rep, null, 2)); process.exit(bad ? 1 : 0); return; }
|
|
1336
|
+
console.log(`thunder test ${basename(file)} --coverage: ${rep.overall}% overall`);
|
|
1337
|
+
console.log('');
|
|
1338
|
+
for (const m of rep.metrics) console.log(` ${m.name.padEnd(16)} ${`${m.covered}/${m.total}`.padStart(7)} ${String(m.pct).padStart(3)}%`);
|
|
1339
|
+
if (rep.unverified.length) {
|
|
1340
|
+
console.log('');
|
|
1341
|
+
console.log(' Unverified:');
|
|
1342
|
+
for (const u of rep.unverified) console.log(` - ${u}`);
|
|
1343
|
+
}
|
|
1344
|
+
process.exit(bad ? 1 : 0);
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// AI evaluations: probabilistic behavior graded against a dataset by metric thresholds.
|
|
1349
|
+
// Declare the bar in ThunderLang; feed measured metrics via --results to gate on them.
|
|
1350
|
+
// `thunder test <file> --evals [--results <json|path>] [--strict]`.
|
|
1351
|
+
if (args.evals) {
|
|
1352
|
+
let results = null;
|
|
1353
|
+
if (args.results) {
|
|
1354
|
+
try { results = existsSync(args.results) ? JSON.parse(readFileSync(args.results, 'utf8')) : JSON.parse(args.results); }
|
|
1355
|
+
catch { console.error('thunder test --evals: --results must be a JSON file path or inline JSON of {metric: value}'); process.exit(2); return; }
|
|
1356
|
+
}
|
|
1357
|
+
const cmp = { '>=': (a, b) => a >= b, '<=': (a, b) => a <= b, '>': (a, b) => a > b, '<': (a, b) => a < b, '==': (a, b) => a === b, '!=': (a, b) => a !== b };
|
|
1358
|
+
const evals = (ast.evaluations || []).map((ev) => {
|
|
1359
|
+
if (!results) return { name: ev.name, dataset: ev.dataset, status: 'declared', requires: ev.requires };
|
|
1360
|
+
const checks = ev.requires.map((r) => {
|
|
1361
|
+
const have = results[r.metric];
|
|
1362
|
+
if (have === undefined) return { ...r, actual: null, pass: false, missing: true };
|
|
1363
|
+
return { ...r, actual: Number(have), pass: cmp[r.op](Number(have), r.threshold) };
|
|
1364
|
+
});
|
|
1365
|
+
return { name: ev.name, dataset: ev.dataset, status: checks.every((c) => c.pass) ? 'passed' : 'failed', checks };
|
|
1366
|
+
});
|
|
1367
|
+
const failed = evals.filter((e) => e.status === 'failed').length;
|
|
1368
|
+
const declared = evals.filter((e) => e.status === 'declared').length;
|
|
1369
|
+
const bad = failed > 0 || (args.strict && declared > 0);
|
|
1370
|
+
const rep = { schema: 'thunder-evals-v1', mission: ast.mission, file: basename(file), total: evals.length, passed: evals.filter((e) => e.status === 'passed').length, declared, failed, results: evals };
|
|
1371
|
+
if (args.json) { console.log(JSON.stringify(rep, null, 2)); process.exit(bad ? 1 : 0); return; }
|
|
1372
|
+
if (!rep.total) { console.log(`thunder test ${basename(file)} --evals: no evaluation blocks found.`); return; }
|
|
1373
|
+
console.log(`thunder test ${basename(file)} --evals: ${rep.total} evaluation(s)${results ? `, ${rep.passed} passed${failed ? `, ${failed} failed` : ''}` : ' (declared , provide --results to grade)'}`);
|
|
1374
|
+
for (const e of evals) {
|
|
1375
|
+
const label = e.status === 'passed' ? 'PASS' : e.status === 'failed' ? 'FAIL' : 'DECLARED';
|
|
1376
|
+
console.log(` ${label.padEnd(9)} ${e.name}${e.dataset ? ` (dataset ${e.dataset})` : ''}`);
|
|
1377
|
+
for (const r of (e.checks || e.requires)) {
|
|
1378
|
+
const mark = e.checks ? (r.pass ? '✓' : '✗') : '·';
|
|
1379
|
+
const actual = e.checks ? (r.missing ? ' (metric not in results)' : ` (${r.actual})`) : '';
|
|
1380
|
+
console.log(` ${mark} ${r.metric} ${r.op} ${r.threshold}${actual}`);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
process.exit(bad ? 1 : 0);
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// Mutation testing: inject faults into decisions and check the tests catch them.
|
|
1388
|
+
// A survived mutant is a weak spot. `thunder test <file> --mutate [--strict]`.
|
|
1389
|
+
if (args.mutate) {
|
|
1390
|
+
const rep = runMutations(ast);
|
|
1391
|
+
const bad = args.strict && rep.survived > 0;
|
|
1392
|
+
if (args.json) { console.log(JSON.stringify(rep, null, 2)); process.exit(bad ? 1 : 0); return; }
|
|
1393
|
+
if (rep.total === 0) { console.log(`thunder test ${basename(file)} --mutate: ${rep.note}`); return; }
|
|
1394
|
+
console.log(`thunder test ${basename(file)} --mutate: mutation score ${rep.score}% (${rep.killed} killed, ${rep.survived} survived of ${rep.total})`);
|
|
1395
|
+
const survivors = rep.results.filter((r) => !r.killed);
|
|
1396
|
+
if (survivors.length) { console.log(' survived , the tests did not catch these (weak spots):'); for (const s of survivors) console.log(` - ${s.describe}`); }
|
|
1397
|
+
process.exit(bad ? 1 : 0);
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Contract-test derivation: every `guarantee` and `never` is a test obligation.
|
|
1402
|
+
// Honest resolution: no verification -> UNVERIFIED (never silently PASS); a declared
|
|
1403
|
+
// verification with the file's checks green -> PASS; a failing in-file check -> FAIL.
|
|
1404
|
+
// `--strict` also fails the run on any UNVERIFIED obligation (CI: nothing left unproven).
|
|
1405
|
+
if (args.contracts) {
|
|
1406
|
+
const res = resolveObligations(ast);
|
|
1407
|
+
const obligations = res.obligations;
|
|
1408
|
+
const rep = { schema: 'thunder-contracts-v1', mission: ast.mission, file: basename(file),
|
|
1409
|
+
total: obligations.length, verified: res.verified, declared: res.declared,
|
|
1410
|
+
unverified: res.unverified, failed: res.failed, obligations };
|
|
1411
|
+
const bad = rep.failed > 0 || (args.strict && rep.unverified > 0);
|
|
1412
|
+
if (args.json) { console.log(JSON.stringify(rep, null, 2)); process.exit(bad ? 1 : 0); return; }
|
|
1413
|
+
if (!rep.total) { console.log(`thunder test ${basename(file)} --contracts: no guarantees or prohibitions declared.`); return; }
|
|
1414
|
+
console.log(`thunder test ${basename(file)} --contracts: ${rep.total} obligation(s), ${rep.verified} verified, ${rep.declared} declared, ${rep.unverified} unverified${rep.failed ? `, ${rep.failed} failed` : ''}`);
|
|
1415
|
+
const LABEL = { verified: 'PASS', failed: 'FAIL', declared: 'DECLARED', unverified: 'UNVERIFIED' };
|
|
1416
|
+
for (const o of obligations) {
|
|
1417
|
+
const by = o.kinds.length ? ` by ${o.kinds.join('+')}` : '';
|
|
1418
|
+
const detail = o.provenBy ? ` proven by test ${o.provenBy}`
|
|
1419
|
+
: o.status === 'declared' ? ' (declared, runs in target mode)'
|
|
1420
|
+
: o.status === 'unverified' ? ' (nothing verifies this)' : '';
|
|
1421
|
+
console.log(` ${LABEL[o.status].padEnd(10)} ${o.kind} ${o.id}: ${o.text}${by}${detail}`);
|
|
1422
|
+
}
|
|
1423
|
+
if (args.strict && rep.unverified) console.log(`\n strict: ${rep.unverified} unverified obligation(s) , run fails until every claim carries a verification.`);
|
|
1424
|
+
process.exit(bad ? 1 : 0);
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
961
1428
|
const r = runTests(ast);
|
|
962
1429
|
if (args.json) { console.log(JSON.stringify(r, null, 2)); process.exit(r.ok ? 0 : 1); return; }
|
|
963
1430
|
if (r.total === 0) { console.log(`thunder test ${basename(file)}: no test blocks found.`); return; }
|
|
@@ -972,6 +1439,141 @@ test Example
|
|
|
972
1439
|
return;
|
|
973
1440
|
}
|
|
974
1441
|
|
|
1442
|
+
// PROVE: run the tests, evaluate guarantee / prohibition obligations, and emit a durable
|
|
1443
|
+
// intent-proof-v1 artifact with honest statuses. Unverified claims never read as passed.
|
|
1444
|
+
// `thunder prove <file> [--out <dir>] [--json]`.
|
|
1445
|
+
if (cmd === 'prove') {
|
|
1446
|
+
if (!file || !existsSync(file)) { console.error('usage: thunder prove <file> [--out <dir>] [--json]'); process.exit(2); return; }
|
|
1447
|
+
const source = readFileSync(file, 'utf8');
|
|
1448
|
+
const ast = parseIntent(source);
|
|
1449
|
+
const diagnostics = semanticDiagnostics(ast);
|
|
1450
|
+
const resolved = resolveObligations(ast);
|
|
1451
|
+
const tests = resolved.tests;
|
|
1452
|
+
const proof = buildProof(ast, {
|
|
1453
|
+
sourceFile: basename(file),
|
|
1454
|
+
sourceHash: sha256(source),
|
|
1455
|
+
targetsRequested: ast.targets || [],
|
|
1456
|
+
targetsGenerated: [],
|
|
1457
|
+
diagnostics,
|
|
1458
|
+
generatedAt: new Date().toISOString(),
|
|
1459
|
+
});
|
|
1460
|
+
// Fold per-claim verdicts into the proof so it records real status, not just planned/needs_verification.
|
|
1461
|
+
const CLAIM_MAP = { verified: 'verified', failed: 'failed', declared: 'planned', unverified: 'needs_verification' };
|
|
1462
|
+
const byId = new Map(resolved.obligations.map((o) => [o.id, o]));
|
|
1463
|
+
const applyStatus = (claim) => { const o = byId.get(claim.id); return o ? { ...claim, status: CLAIM_MAP[o.status], provenBy: o.provenBy || null } : claim; };
|
|
1464
|
+
proof.guarantees = proof.guarantees.map(applyStatus);
|
|
1465
|
+
proof.neverRules = proof.neverRules.map(applyStatus);
|
|
1466
|
+
// Freshness tuple: what `thunder verify` recomputes to mark this proof STALE later.
|
|
1467
|
+
proof.freshness = freshnessFor(file, proof, args.env);
|
|
1468
|
+
const proofId = `proof-${proof.sourceHash.replace('sha256:', '').slice(0, 6)}`;
|
|
1469
|
+
const fail = !proof.verification.semanticPassed || !tests.ok || resolved.failed > 0;
|
|
1470
|
+
|
|
1471
|
+
if (args.json) {
|
|
1472
|
+
console.log(JSON.stringify({ proofId, ...proof, tests }, null, 2));
|
|
1473
|
+
process.exit(fail ? 1 : 0);
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
console.log(`Proof created: ${proofId}`);
|
|
1478
|
+
console.log('');
|
|
1479
|
+
console.log(` Intent: ${proof.missionName || stripSourceExt(basename(file))}`);
|
|
1480
|
+
console.log(` Intent hash: ${proof.sourceHash}`);
|
|
1481
|
+
console.log(` Compiler: ThunderLang ${proof.compilerVersion}`);
|
|
1482
|
+
console.log(` Generated: ${proof.generatedAt}`);
|
|
1483
|
+
console.log(` Syntax: ${proof.verification.syntaxPassed ? 'PASS' : 'FAIL'}`);
|
|
1484
|
+
console.log(` Semantics: ${proof.verification.semanticPassed ? 'PASS' : 'FAIL'}`);
|
|
1485
|
+
console.log(` Tests: ${tests.total ? `${tests.passed}/${tests.total} passed` : 'none'}`);
|
|
1486
|
+
console.log(` Claims: ${resolved.verified} verified, ${resolved.declared} declared, ${resolved.unverified} UNVERIFIED${resolved.failed ? `, ${resolved.failed} FAILED` : ''} (of ${resolved.obligations.length})`);
|
|
1487
|
+
console.log(` Proof status: ${String(proof.proofStatus).toUpperCase()}${(resolved.unverified || resolved.failed) ? ' (not fully proven)' : ''}`);
|
|
1488
|
+
const fr = proof.freshness;
|
|
1489
|
+
console.log(` Bound to: commit ${fr.implementation ? fr.implementation.slice(0, 7) : 'n/a'}${fr.dependencies ? ` · deps ${fr.dependencies.hash.replace('sha256:', '').slice(0, 7)} (${fr.dependencies.file})` : ''}${fr.environment ? ` · env ${fr.environment}` : ''}`);
|
|
1490
|
+
const problems = resolved.obligations.filter((o) => o.status === 'unverified' || o.status === 'failed');
|
|
1491
|
+
if (problems.length) {
|
|
1492
|
+
console.log('');
|
|
1493
|
+
console.log(' Not proven (this is where drift hides , a claim like this never counts as proven):');
|
|
1494
|
+
for (const o of problems) console.log(` - ${o.status === 'failed' ? 'FAILED ' : 'UNVERIFIED'} ${o.kind} ${o.id}: ${o.text}`);
|
|
1495
|
+
}
|
|
1496
|
+
const outName = `${slug(ast.mission || stripSourceExt(basename(file)))}.thunder-proof.json`;
|
|
1497
|
+
const outDir = args.out && args.out !== '.intent' ? args.out : dirname(file);
|
|
1498
|
+
const outPath = join(outDir, outName);
|
|
1499
|
+
writeFileSync(outPath, JSON.stringify(proof, null, 2));
|
|
1500
|
+
console.log('');
|
|
1501
|
+
console.log(` wrote ${relative(process.cwd(), outPath)}`);
|
|
1502
|
+
process.exit(fail ? 1 : 0);
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// CONFORMANCE: the same test cases run against every target. The engine defines the canonical
|
|
1507
|
+
// result each case must produce; target outputs fed via --results are graded against it.
|
|
1508
|
+
// `thunder conform <file> [--targets ts,py] [--results <json|path>] [--json]`.
|
|
1509
|
+
if (cmd === 'conform') {
|
|
1510
|
+
const ast = parseIntent(readFileSync(file, 'utf8'));
|
|
1511
|
+
let results = null;
|
|
1512
|
+
if (args.results) {
|
|
1513
|
+
try { results = existsSync(args.results) ? JSON.parse(readFileSync(args.results, 'utf8')) : JSON.parse(args.results); }
|
|
1514
|
+
catch { console.error('thunder conform: --results must be JSON of {target: {"Test / case": result}} (file path or inline)'); process.exit(2); return; }
|
|
1515
|
+
}
|
|
1516
|
+
let targets = (args.targets && args.targets.length ? args.targets : (ast.targets || [])).map((t) => canonicalTarget(t));
|
|
1517
|
+
const skippedTargets = [];
|
|
1518
|
+
// --all-targets: execute EVERY target whose toolchain is available in one pass, and show all
|
|
1519
|
+
// live targets as columns (unavailable ones stay "declared" with a skipped note).
|
|
1520
|
+
if (args.allTargets) {
|
|
1521
|
+
results = results || {};
|
|
1522
|
+
targets = Array.from(new Set([...targets, ...LIVE_TARGETS.map((t) => t.key)]));
|
|
1523
|
+
for (const t of LIVE_TARGETS) {
|
|
1524
|
+
if (!t.available()) { skippedTargets.push(t.key); continue; }
|
|
1525
|
+
const actual = t.run(ast);
|
|
1526
|
+
if (actual !== null) results[t.key] = actual; else skippedTargets.push(t.key);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
// --run <targets>: execute specific live targets (TypeScript/JS/Python/C#/Java) and grade real
|
|
1530
|
+
// outputs. A null result (e.g. the SDK is not installed) is skipped, leaving it "declared".
|
|
1531
|
+
if (args.run && args.run.length) {
|
|
1532
|
+
results = results || {};
|
|
1533
|
+
for (const rt of args.run) {
|
|
1534
|
+
if (!RUNNABLE_TARGETS.has(rt.toLowerCase())) continue;
|
|
1535
|
+
const key = canonicalTarget(rt);
|
|
1536
|
+
const actual = runLiveTarget(ast, key);
|
|
1537
|
+
if (actual !== null) results[key] = actual;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
const rep = buildConformance(ast, { targets, results });
|
|
1541
|
+
if (skippedTargets.length) rep.skipped = Array.from(new Set(skippedTargets));
|
|
1542
|
+
const bad = rep.semanticFailures > 0 || rep.failures.length > 0;
|
|
1543
|
+
if (args.json) { console.log(JSON.stringify(rep, null, 2)); process.exit(bad ? 1 : 0); return; }
|
|
1544
|
+
if (!rep.total) { console.log(`thunder conform ${basename(file)}: no test cases to conform.`); return; }
|
|
1545
|
+
|
|
1546
|
+
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
1547
|
+
const cols = ['Semantic', ...rep.columns.map(cap)];
|
|
1548
|
+
const labelW = Math.max(12, ...rep.cases.map((c) => c.key.length));
|
|
1549
|
+
const cw = (h) => Math.max(h.length, 8);
|
|
1550
|
+
const cell = (v, w) => String(v).padEnd(w);
|
|
1551
|
+
console.log(`thunder conform ${basename(file)}: ${rep.total} case(s) · semantic + ${rep.columns.length} target(s)${rep.graded ? '' : ' (declared , provide --results to grade targets)'}`);
|
|
1552
|
+
console.log(` ${cell('', labelW)} ${cols.map((h) => cell(h, cw(h))).join(' ')}`);
|
|
1553
|
+
for (const c of rep.cases) {
|
|
1554
|
+
const sem = c.semanticPass ? 'PASS' : 'FAIL';
|
|
1555
|
+
const tcells = rep.columns.map((col) => {
|
|
1556
|
+
const s = c.targets[col].status;
|
|
1557
|
+
return cell(s === 'pass' ? 'PASS' : s === 'fail' ? 'FAIL' : '—', cw(cap(col)));
|
|
1558
|
+
});
|
|
1559
|
+
console.log(` ${cell(c.key, labelW)} ${cell(sem, cw('Semantic'))} ${tcells.join(' ')}`);
|
|
1560
|
+
}
|
|
1561
|
+
for (const f of rep.failures) {
|
|
1562
|
+
console.log('');
|
|
1563
|
+
console.log(' CONFORMANCE FAILURE');
|
|
1564
|
+
console.log(` Target: ${cap(f.target)}`);
|
|
1565
|
+
console.log(` Case: ${f.case}`);
|
|
1566
|
+
console.log(` Expected: ${f.expected}`);
|
|
1567
|
+
console.log(` Actual: ${f.actual}`);
|
|
1568
|
+
}
|
|
1569
|
+
if (rep.skipped && rep.skipped.length) {
|
|
1570
|
+
console.log('');
|
|
1571
|
+
console.log(` Skipped (toolchain not available, left declared): ${rep.skipped.map(cap).join(', ')}`);
|
|
1572
|
+
}
|
|
1573
|
+
process.exit(bad ? 1 : 0);
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
975
1577
|
// Intent Runtime: SIMULATE a lifecycle against a sequence of events.
|
|
976
1578
|
if (cmd === 'simulate') {
|
|
977
1579
|
const ast = parseIntent(readFileSync(file, 'utf8'));
|
|
@@ -1507,6 +2109,13 @@ test Example
|
|
|
1507
2109
|
}
|
|
1508
2110
|
|
|
1509
2111
|
const generated = [];
|
|
2112
|
+
// `thunder graph <file> --safe` , a display-safe intent-graph on stdout, for external viewers
|
|
2113
|
+
// (e.g. STT rendering the Intent Atlas). Strips owner/source provenance and redacts sensitive
|
|
2114
|
+
// classifications; carries no source code.
|
|
2115
|
+
if (cmd === 'graph' && args.safe) {
|
|
2116
|
+
console.log(JSON.stringify(safeGraph(buildIntentGraph(ast)), null, 2));
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
1510
2119
|
if (cmd === 'graph' || cmd === 'build') {
|
|
1511
2120
|
generated.push(writeJson(outDir, 'contract-graph.json', buildContractGraph(ast, generatedAt)));
|
|
1512
2121
|
generated.push(writeJson(outDir, 'architecture-graph.json', buildArchitectureGraph(ast, generatedAt)));
|
|
@@ -1525,7 +2134,7 @@ test Example
|
|
|
1525
2134
|
targetsGenerated: generated.map((p) => p.replace(process.cwd() + '/', '')),
|
|
1526
2135
|
diagnostics,
|
|
1527
2136
|
});
|
|
1528
|
-
generated.push(writeJson(outDir, '.
|
|
2137
|
+
generated.push(writeJson(outDir, '.thunder-proof.json', proof));
|
|
1529
2138
|
}
|
|
1530
2139
|
if (!['graph', 'proof', 'build'].includes(cmd)) {
|
|
1531
2140
|
console.error(`unknown command: ${cmd}`);
|