@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/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] .intent-proof.json
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
- const LIFT_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.rs', '.pl', '.pm'];
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 deterministic ThunderLang compiler (no AI required)
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
- usage: intent <command> <file> [options]
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.intent)
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 .intent-proof.json
230
- graph <file> the canonical Intent Graph (intent-graph-v1)
231
- proof <file> the .intent-proof.json artifact
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 .intent from a graph
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 .intent-proof.json against its source: the source hash still matches (no
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
- const result = { schema: 'intent-verify-v1', proof: proofPath, source: srcPath, valid, checks: { wellFormed: structure.valid, hashMatch, semanticMatch, guaranteesMatch, neverMatch }, structureErrors: structure.errors };
316
- if (args.json) { console.log(JSON.stringify(result, null, 2)); process.exit(valid ? 0 : 1); return; }
317
- console.log(`thunder verify ${basename(proofPath)}: ${valid ? 'VALID' : 'FAILED'} (source ${basename(srcPath)})`);
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 (valid) console.log(` ok proof matches source (hash + ${(proof.guarantees || []).length} guarantee(s), ${(proof.neverRules || []).length} never-rule(s)).`);
324
- process.exit(valid ? 0 : 1);
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 init: wrote ${target}`);
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 || 'typescript', file: basename(file) });
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, '.intent-proof.json', proof));
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}`);