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