@skillstech/thunderlang 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +375 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -0
  4. package/dist/core.cjs +6768 -0
  5. package/dist/index.cjs +9308 -0
  6. package/intent-graph.schema.json +687 -0
  7. package/package.json +84 -0
  8. package/src/ai-core.mjs +67 -0
  9. package/src/ai-events.mjs +56 -0
  10. package/src/ai.mjs +324 -0
  11. package/src/arch.mjs +55 -0
  12. package/src/atlas.mjs +118 -0
  13. package/src/changes.mjs +74 -0
  14. package/src/classification.mjs +36 -0
  15. package/src/cli.mjs +1534 -0
  16. package/src/codegen.mjs +214 -0
  17. package/src/compile.mjs +142 -0
  18. package/src/comprehension.mjs +130 -0
  19. package/src/conflict.mjs +0 -0
  20. package/src/core.d.ts +99 -0
  21. package/src/core.mjs +92 -0
  22. package/src/data-schema.mjs +137 -0
  23. package/src/decision.mjs +38 -0
  24. package/src/distributed.mjs +48 -0
  25. package/src/draft.mjs +101 -0
  26. package/src/drift.mjs +177 -0
  27. package/src/emit.mjs +519 -0
  28. package/src/exporters.mjs +245 -0
  29. package/src/expr.mjs +245 -0
  30. package/src/fable.mjs +110 -0
  31. package/src/focus.mjs +151 -0
  32. package/src/format.mjs +55 -0
  33. package/src/governance.mjs +100 -0
  34. package/src/graph-source.mjs +292 -0
  35. package/src/guard.mjs +105 -0
  36. package/src/guardian.mjs +98 -0
  37. package/src/hash.mjs +89 -0
  38. package/src/importers.mjs +194 -0
  39. package/src/index.d.ts +725 -0
  40. package/src/index.mjs +185 -0
  41. package/src/intellisense.mjs +210 -0
  42. package/src/intent-atlas.mjs +77 -0
  43. package/src/intent-graph.mjs +346 -0
  44. package/src/intent-ir.mjs +144 -0
  45. package/src/intent-schema.mjs +215 -0
  46. package/src/ledger.mjs +109 -0
  47. package/src/lifecycle.mjs +56 -0
  48. package/src/lift.mjs +693 -0
  49. package/src/lsp.mjs +152 -0
  50. package/src/mcp.mjs +158 -0
  51. package/src/migrate.mjs +118 -0
  52. package/src/outcome.mjs +93 -0
  53. package/src/parse.mjs +733 -0
  54. package/src/patch.mjs +364 -0
  55. package/src/privacy.mjs +61 -0
  56. package/src/proof-schema.mjs +129 -0
  57. package/src/report.mjs +84 -0
  58. package/src/runtime.mjs +96 -0
  59. package/src/sarif.mjs +88 -0
  60. package/src/scan-queries.mjs +97 -0
  61. package/src/scan.mjs +87 -0
  62. package/src/security.mjs +73 -0
  63. package/src/select.mjs +80 -0
  64. package/src/semantic-diff.mjs +125 -0
  65. package/src/simulate.mjs +106 -0
  66. package/src/style.mjs +250 -0
  67. package/src/sync.mjs +103 -0
  68. package/src/testing.mjs +59 -0
  69. package/src/twelve-factor.mjs +173 -0
  70. package/src/verify-diff.mjs +105 -0
  71. package/src/xml.mjs +87 -0
  72. package/syntaxes/intent.tmLanguage.json +55 -0
@@ -0,0 +1,106 @@
1
+ // Intent Simulator (intent-simulate-v1) , estimate the impact of a proposed change BEFORE it is
2
+ // implemented. Where Intent Guardian reports drift AFTER a change, the Simulator runs it forward on
3
+ // a hypothetical: given a base and a proposed intent, it computes the blast radius (which nodes the
4
+ // change transitively touches), the risk it would introduce, contradictions, and release risk , and
5
+ // it clearly SEPARATES deterministic dependency impact from rule-derived risk from unknown impact,
6
+ // per the directive. Deterministic, no AI. Composes the shared spine (diffGraphs + scanProject +
7
+ // the Intent Graph). Pure ESM.
8
+ //
9
+ // simulateChange(baseFiles, proposedFiles) -> an impact estimate
10
+
11
+ import { parseIntent } from './parse.mjs';
12
+ import { buildIntentGraph } from './intent-graph.mjs';
13
+ import { diffGraphs } from './semantic-diff.mjs';
14
+ import { scanProject } from './scan.mjs';
15
+ import { isFactual } from './classification.mjs';
16
+
17
+ export const SIMULATE_SCHEMA = 'intent-simulate-v1';
18
+
19
+ function mergedGraph(files) {
20
+ const nodes = []; const relationships = []; const seen = new Set();
21
+ for (const { source } of files || []) {
22
+ const g = buildIntentGraph(parseIntent(String(source ?? '')));
23
+ for (const n of g.nodes) if (!seen.has(n.id)) { seen.add(n.id); nodes.push(n); }
24
+ relationships.push(...g.relationships);
25
+ }
26
+ return { nodes, relationships };
27
+ }
28
+
29
+ // Transitive reach from the changed nodes over the (undirected) relationship graph, bounded by
30
+ // depth , the deterministic dependency blast radius. Returns a Set of affected node ids.
31
+ function blastRadius(graph, changedIds, maxDepth = 3) {
32
+ const adj = new Map();
33
+ const link = (a, b) => { (adj.get(a) ?? adj.set(a, new Set()).get(a)).add(b); };
34
+ for (const r of graph.relationships || []) { link(r.from, r.to); link(r.to, r.from); }
35
+ const reached = new Set(changedIds);
36
+ let frontier = [...changedIds];
37
+ for (let d = 0; d < maxDepth && frontier.length; d += 1) {
38
+ const next = [];
39
+ for (const id of frontier) for (const nb of adj.get(id) ?? []) if (!reached.has(nb)) { reached.add(nb); next.push(nb); }
40
+ frontier = next;
41
+ }
42
+ return reached;
43
+ }
44
+
45
+ const groupByType = (nodes) => {
46
+ const by = {};
47
+ for (const n of nodes) (by[n.type] = by[n.type] || []).push({ id: n.id, title: n.title || null });
48
+ return by;
49
+ };
50
+
51
+ /**
52
+ * Estimate the impact of moving from `baseFiles` to `proposedFiles`.
53
+ * @param {Array<{file:string,source:string}>} baseFiles
54
+ * @param {Array<{file:string,source:string}>} proposedFiles
55
+ */
56
+ export function simulateChange(baseFiles, proposedFiles) {
57
+ const baseGraph = mergedGraph(baseFiles);
58
+ const proposedGraph = mergedGraph(proposedFiles);
59
+ const diff = diffGraphs(baseGraph, proposedGraph);
60
+
61
+ // Union graph so reachability covers both removed (base) and added (proposed) nodes.
62
+ const unionNodes = new Map();
63
+ for (const n of [...baseGraph.nodes, ...proposedGraph.nodes]) if (!unionNodes.has(n.id)) unionNodes.set(n.id, n);
64
+ const union = { nodes: [...unionNodes.values()], relationships: [...baseGraph.relationships, ...proposedGraph.relationships] };
65
+
66
+ const changedIds = [...new Set([...diff.addedNodes.map((n) => n.id), ...diff.removedNodes.map((n) => n.id), ...diff.changedNodes.map((c) => c.id)])];
67
+ const reached = blastRadius(union, changedIds);
68
+ const affectedNodes = [...reached].map((id) => unionNodes.get(id)).filter(Boolean);
69
+ // The impact set minus the directly-changed nodes = the DEPENDENTS the change ripples out to.
70
+ const changedSet = new Set(changedIds);
71
+ const dependents = affectedNodes.filter((n) => !changedSet.has(n.id));
72
+
73
+ // Rule-derived risk: findings the proposed state has that the base did not.
74
+ const baseScan = scanProject(baseFiles);
75
+ const proposedScan = scanProject(proposedFiles);
76
+ const baseKeys = new Set(baseScan.findings.map((f) => `${f.ruleId}|${f.detected}`));
77
+ const introducedRisk = proposedScan.findings.filter((f) => !baseKeys.has(`${f.ruleId}|${f.detected}`));
78
+ const contradictions = proposedScan.findings.filter((f) => f.category === 'Intent risk' && /IL-CONFLICT/.test(f.ruleId));
79
+ const releaseRisks = introducedRisk.filter((f) => f.severity === 'blocker' || f.severity === 'error');
80
+
81
+ // Unknown impact: changed nodes whose classification is not factual (the estimate is uncertain
82
+ // there), and any changed node with no relationships (its ripple can't be traced deterministically).
83
+ const linked = new Set(union.relationships.flatMap((r) => [r.from, r.to]));
84
+ const unknownImpact = affectedNodes.filter((n) => (n.classification && !isFactual(n.classification)) || !linked.has(n.id))
85
+ .map((n) => ({ id: n.id, type: n.type, reason: n.classification && !isFactual(n.classification) ? `${n.classification} , impact is uncertain` : 'no relationships , ripple cannot be traced deterministically' }));
86
+
87
+ return {
88
+ schema: SIMULATE_SCHEMA,
89
+ changedNodes: changedIds.length,
90
+ // deterministic dependency impact , what the change transitively touches, by type
91
+ deterministicImpact: { total: dependents.length, byType: groupByType(dependents) },
92
+ ruleDerivedRisk: introducedRisk,
93
+ contradictions,
94
+ releaseRisks,
95
+ aiPredictedImpact: null, // not computed in deterministic mode (no AI); reserved for the Engine
96
+ unknownImpact,
97
+ summary: {
98
+ changed: changedIds.length,
99
+ dependentsAffected: dependents.length,
100
+ introducedRisk: introducedRisk.length,
101
+ releaseBlocking: releaseRisks.length,
102
+ contradictions: contradictions.length,
103
+ safe: releaseRisks.length === 0 && contradictions.length === 0,
104
+ },
105
+ };
106
+ }
package/src/style.mjs ADDED
@@ -0,0 +1,250 @@
1
+ // Style Intent (intent-style-v1) , brand + visual language as a governed, deterministic
2
+ // extension of the Experience profile. IL owns the canonical token address space and the
3
+ // accessibility-target vocabulary so ST (Studio), STW (theming), and every renderer bind
4
+ // to ONE schema instead of forking their own token trees.
5
+ //
6
+ // Two load-bearing rules, enforced here (no AI):
7
+ // 1. Tokens address a CANONICAL, lockable namespace. An off-namespace path is a typo or
8
+ // a private fork , flagged, so the design system stays a closed vocabulary.
9
+ // 2. `accessibility_target` is ALWAYS a PROPOSED claim. IL never lets a StyleIntent
10
+ // assert it is `verified`/compliant , conformance is OT's verdict after real testing.
11
+
12
+ import { slug } from './parse.mjs';
13
+
14
+ export const STYLE_SCHEMA = 'intent-style-v1';
15
+
16
+ // Canonical, lockable design-token address space. A StyleIntent may only bind these paths
17
+ // (plus the non-token brand.* addresses below). Extending the set is a request to IL, not a
18
+ // private addition , that is what keeps themes portable across Studio, storefronts, and docs.
19
+ export const TOKEN_PATHS = [
20
+ 'color.primary', 'color.accent', 'color.surface', 'color.text',
21
+ 'color.feedback.success', 'color.feedback.warning', 'color.feedback.error', 'color.feedback.info',
22
+ 'typography.scale', 'typography.headingWeight',
23
+ 'typography.families.heading', 'typography.families.body', 'typography.families.mono',
24
+ 'shape.radius', 'shape.borders', 'shape.elevation',
25
+ 'density', 'mode',
26
+ ];
27
+
28
+ // Non-token, lockable brand addresses (assets, not scalar tokens).
29
+ export const BRAND_PATHS = ['brand.logo'];
30
+
31
+ // The full lockable address space a StyleIntent may bind.
32
+ export const STYLE_ADDRESS_SPACE = [...TOKEN_PATHS, ...BRAND_PATHS];
33
+
34
+ // Accessibility targets IL recognizes. Always a GOAL (classification `proposed`), never a
35
+ // proof of conformance. `mode` token values are constrained separately.
36
+ export const ACCESSIBILITY_TARGETS = ['WCAG_2_1_AA', 'WCAG_2_2_AA', 'WCAG_2_2_AAA'];
37
+
38
+ // Legal values for the `mode` token.
39
+ export const MODE_VALUES = ['light', 'dark', 'auto', 'both'];
40
+
41
+ /** IL's stance: an accessibility target is a proposed goal, never IL-verified. */
42
+ export const ACCESSIBILITY_CLASSIFICATION = 'proposed';
43
+
44
+ export const DESIGN_TOKENS_SCHEMA = 'intent-design-tokens-v1';
45
+
46
+ // A canonical token address -> a CSS custom-property name: dots become hyphens and camelCase
47
+ // segments are kebab-cased. `color.feedback.error` -> `--color-feedback-error`,
48
+ // `typography.headingWeight` -> `--typography-heading-weight`.
49
+ function cssVarName(path) {
50
+ return `--${String(path).split('.').map((seg) => seg.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()).join('-')}`;
51
+ }
52
+
53
+ const isKnownPath = (p) => STYLE_ADDRESS_SPACE.includes(p);
54
+
55
+ // W3C Design Tokens (DTCG) `$type` for a canonical token address.
56
+ function tokenType(path) {
57
+ if (path === 'brand.logo') return 'asset';
58
+ if (path === 'mode') return 'other';
59
+ if (path === 'density') return 'number';
60
+ if (path.startsWith('color.')) return 'color';
61
+ if (path.startsWith('typography.families.')) return 'fontFamily';
62
+ if (path === 'typography.scale' || path === 'typography.headingWeight') return 'number';
63
+ if (path.startsWith('shape.')) return 'dimension';
64
+ return 'other';
65
+ }
66
+
67
+ // Coerce a raw token value: bare numerics become numbers; everything else stays a string.
68
+ function coerceValue(v) {
69
+ if (v == null) return null;
70
+ return /^-?\d+(?:\.\d+)?$/.test(v) ? Number(v) : v;
71
+ }
72
+
73
+ // Set a dotted address to a leaf value inside a nested group tree (last write wins).
74
+ function setPath(root, path, leaf) {
75
+ const parts = path.split('.');
76
+ let cur = root;
77
+ for (let i = 0; i < parts.length - 1; i += 1) {
78
+ const k = parts[i];
79
+ if (typeof cur[k] !== 'object' || cur[k] === null || '$value' in cur[k]) cur[k] = {};
80
+ cur = cur[k];
81
+ }
82
+ cur[parts[parts.length - 1]] = leaf;
83
+ }
84
+
85
+ /**
86
+ * Deterministic diagnostics for every `style_intent` block. Returns an array of
87
+ * { ruleId, severity, blocks, message, styleIntent, line } , the same shape the rest of
88
+ * the compiler emits, so it composes into `intent check` with no special-casing.
89
+ */
90
+ export function styleDiagnostics(ast) {
91
+ const out = [];
92
+ const styles = ast.styleIntents || [];
93
+ const experienceNames = new Set((ast.experiences || []).map((e) => slug(e.name || '')));
94
+
95
+ for (const si of styles) {
96
+ const where = si.line;
97
+ const label = si.name || '(unnamed)';
98
+
99
+ // IL-STYLE-001 , off-namespace token path (typo or private fork of the token tree).
100
+ for (const t of si.tokens) {
101
+ if (!isKnownPath(t.path)) {
102
+ out.push({
103
+ ruleId: 'IL-STYLE-001', severity: 'warning', blocks: [],
104
+ message: `Style intent "${label}" binds unknown token "${t.path}". Use a canonical address (see intent-style-v1) or request the extension from IL.`,
105
+ styleIntent: label, line: t.line ?? where,
106
+ });
107
+ }
108
+ }
109
+
110
+ // IL-STYLE-002 , unrecognized accessibility target.
111
+ if (si.accessibilityTarget && !ACCESSIBILITY_TARGETS.includes(si.accessibilityTarget)) {
112
+ out.push({
113
+ ruleId: 'IL-STYLE-002', severity: 'warning', blocks: [],
114
+ message: `Style intent "${label}" declares unknown accessibility_target "${si.accessibilityTarget}". Expected one of: ${ACCESSIBILITY_TARGETS.join(', ')}.`,
115
+ styleIntent: label, line: where,
116
+ });
117
+ }
118
+
119
+ // IL-STYLE-003 , no accessibility target declared. A style intent should state the
120
+ // conformance goal it is designing toward (as a proposed claim), so OT can later verify it.
121
+ if (!si.accessibilityTarget) {
122
+ out.push({
123
+ ruleId: 'IL-STYLE-003', severity: 'info', blocks: [],
124
+ message: `Style intent "${label}" declares no accessibility_target. State the goal (e.g. WCAG_2_2_AA) , it is a proposed claim OT verifies, never assumed met.`,
125
+ styleIntent: label, line: where,
126
+ });
127
+ }
128
+
129
+ // IL-STYLE-004 , invalid `mode` token value.
130
+ for (const t of si.tokens) {
131
+ if (t.path === 'mode' && t.value && !MODE_VALUES.includes(t.value)) {
132
+ out.push({
133
+ ruleId: 'IL-STYLE-004', severity: 'warning', blocks: [],
134
+ message: `Style intent "${label}" sets mode "${t.value}"; expected one of: ${MODE_VALUES.join(', ')}.`,
135
+ styleIntent: label, line: t.line ?? where,
136
+ });
137
+ }
138
+ }
139
+
140
+ // IL-STYLE-005 , applies_to references an experience that isn't declared here.
141
+ if (si.appliesTo && experienceNames.size && !experienceNames.has(slug(si.appliesTo))) {
142
+ out.push({
143
+ ruleId: 'IL-STYLE-005', severity: 'info', blocks: [],
144
+ message: `Style intent "${label}" applies_to "${si.appliesTo}", which is not a declared experience in this file.`,
145
+ styleIntent: label, line: where,
146
+ });
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+
152
+ /**
153
+ * A structured, deterministic view of the style intents in a document , the payload
154
+ * Studio/renderers consume. Tokens are resolved against the canonical address space and
155
+ * the accessibility target is stamped with its (always proposed) classification.
156
+ */
157
+ export function analyzeStyle(ast) {
158
+ const styles = (ast.styleIntents || []).map((si) => ({
159
+ id: `style.${slug(si.name || 'style')}`,
160
+ name: si.name,
161
+ appliesTo: si.appliesTo,
162
+ purpose: si.purpose,
163
+ audience: si.audience,
164
+ surfaces: si.surfaces,
165
+ scope: si.scope,
166
+ accessibility: si.accessibilityTarget
167
+ ? { target: si.accessibilityTarget, classification: ACCESSIBILITY_CLASSIFICATION, verified: false }
168
+ : null,
169
+ tokens: si.tokens.map((t) => ({ path: t.path, value: t.value, canonical: isKnownPath(t.path) })),
170
+ }));
171
+ return {
172
+ schema: STYLE_SCHEMA,
173
+ styleIntents: styles,
174
+ diagnostics: styleDiagnostics(ast),
175
+ addressSpace: STYLE_ADDRESS_SPACE,
176
+ accessibilityTargets: ACCESSIBILITY_TARGETS,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Render the style intents' tokens as a W3C Design Tokens (DTCG) document , the standard
182
+ * shape Style Dictionary, Figma Tokens, and CSS pipelines consume. Deterministic and pure.
183
+ * Every declared token is emitted (canonical or not); accessibility targets ride along in
184
+ * `$extensions` as PROPOSED claims, never as verified conformance. Empty-but-valid when a
185
+ * file declares no style intents.
186
+ */
187
+ export function toDesignTokens(ast) {
188
+ const styles = ast.styleIntents || [];
189
+ const root = {};
190
+ const provenance = [];
191
+ for (const si of styles) {
192
+ provenance.push({
193
+ name: si.name || null,
194
+ purpose: si.purpose || null,
195
+ appliesTo: si.appliesTo || null,
196
+ accessibility: si.accessibilityTarget
197
+ ? { target: si.accessibilityTarget, classification: ACCESSIBILITY_CLASSIFICATION, verified: false }
198
+ : null,
199
+ });
200
+ for (const t of si.tokens) {
201
+ const leaf = { $value: coerceValue(t.value), $type: tokenType(t.path) };
202
+ if (!isKnownPath(t.path)) leaf.$extensions = { 'dev.thunderlang': { canonical: false } };
203
+ setPath(root, t.path, leaf);
204
+ }
205
+ }
206
+ return {
207
+ $description: `Design tokens for ${ast.title || ast.mission || 'intent'} (generated from style_intent by @skillstech/thunderlang)`,
208
+ ...root,
209
+ $extensions: {
210
+ 'dev.thunderlang': {
211
+ schema: DESIGN_TOKENS_SCHEMA,
212
+ format: 'W3C Design Tokens (DTCG)',
213
+ source: ast.mission || null,
214
+ styleIntents: provenance,
215
+ note: 'accessibility targets are proposed claims, not verified conformance',
216
+ },
217
+ },
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Render the style intents' tokens as a ready-to-use CSS custom-property sheet , the direct,
223
+ * no-external-tool last mile of the token pipeline. Every canonical address becomes a
224
+ * `--kebab-case` variable on `:root`; a `mode` token drives `color-scheme`; `brand.logo`
225
+ * becomes a `url(...)`. Accessibility targets appear only as comments (proposed, never a
226
+ * guarantee). Deterministic; empty-but-valid `:root {}` when nothing is declared.
227
+ */
228
+ export function toCss(ast) {
229
+ const styles = ast.styleIntents || [];
230
+ const title = ast.title || ast.mission || 'intent';
231
+ const head = [`/* Design tokens for ${title} , generated from style_intent by @skillstech/thunderlang. */`];
232
+ for (const si of styles) {
233
+ if (si.accessibilityTarget) head.push(`/* ${si.name || 'style'}: accessibility target ${si.accessibilityTarget} (proposed, not verified). */`);
234
+ }
235
+ const vars = new Map(); // path -> declaration value (last write wins, dedup, order-stable)
236
+ let colorScheme = null;
237
+ for (const si of styles) {
238
+ for (const t of si.tokens) {
239
+ if (t.path === 'mode') {
240
+ const m = String(t.value || '').toLowerCase();
241
+ colorScheme = m === 'light' ? 'light' : m === 'dark' ? 'dark' : (m === 'both' || m === 'auto') ? 'light dark' : null;
242
+ }
243
+ const value = t.path === 'brand.logo' && t.value ? `url(${t.value})` : (t.value ?? '');
244
+ vars.set(t.path, value);
245
+ }
246
+ }
247
+ const body = [...vars].map(([path, value]) => ` ${cssVarName(path)}: ${value};`);
248
+ if (colorScheme) body.push(` color-scheme: ${colorScheme};`);
249
+ return `${head.join('\n')}\n:root {\n${body.join('\n')}${body.length ? '\n' : ''}}\n`;
250
+ }
package/src/sync.mjs ADDED
@@ -0,0 +1,103 @@
1
+ // Human <-> Structured <-> ThunderLang synchronization (intent-sync-v1). IL owns the boundary
2
+ // between a plain-language / structured editor (SkillsTech Studio) and canonical ThunderLang,
3
+ // so a non-developer edits structured fields and IL stays the source of truth , never a silent
4
+ // rewrite. Pure ESM, ZERO Node deps: browser-safe for Studio's Vite build.
5
+ //
6
+ // parseToStructured(source) -> the canonical structured view (intent graph + flat fields)
7
+ // proposeIntent(structured, { base }) -> { source, diff, ambiguities, warnings } , a PROPOSAL
8
+ // the caller reviews and applies; it is never applied here, and ambiguity is surfaced,
9
+ // not guessed.
10
+
11
+ import { parseIntent } from './parse.mjs';
12
+ import { buildIntentGraph } from './intent-graph.mjs';
13
+ import { graphToSource } from './graph-source.mjs';
14
+ import { diffGraphs } from './semantic-diff.mjs';
15
+ import { validateGraph } from './migrate.mjs';
16
+ import { isFactual } from './classification.mjs';
17
+
18
+ export const SYNC_SCHEMA = 'intent-sync-v1';
19
+
20
+ const hasComments = (src) => typeof src === 'string' && /(^|\s)#/.test(src);
21
+
22
+ /**
23
+ * The canonical structured view of ThunderLang source: the intent graph a projector (Studio's
24
+ * ProjectNode/ProjectEdge) builds from, plus a flat, PM-friendly field summary. IL stays the
25
+ * source of truth; this is a projection of it.
26
+ */
27
+ export function parseToStructured(source) {
28
+ const ast = parseIntent(String(source ?? ''));
29
+ const graph = buildIntentGraph(ast);
30
+ const fields = {
31
+ mission: ast.mission || null,
32
+ title: ast.title || null,
33
+ goal: ast.goal || null,
34
+ why: ast.why || null,
35
+ guarantees: (ast.guarantees || []).map((g) => g.statement),
36
+ neverRules: (ast.neverRules || []).map((n) => n.statement),
37
+ inputs: (ast.inputs || []).map((f) => ({ name: f.name, type: f.type })),
38
+ outputs: (ast.outputs || []).map((f) => ({ name: f.name, type: f.type })),
39
+ decisions: (ast.decisions || []).map((d) => d.name),
40
+ lifecycles: (ast.lifecycles || []).map((l) => l.name),
41
+ outcomes: (ast.outcomes || []).map((o) => o.name),
42
+ };
43
+ return { schema: SYNC_SCHEMA, mission: fields.mission, graph, fields };
44
+ }
45
+
46
+ // Non-factual (proposed/assumed/inferred/unknown) nodes are decisions a human still owes , the
47
+ // "return a proposal, not a guess" surface.
48
+ function collectAmbiguities(graph) {
49
+ const out = [];
50
+ for (const n of graph.nodes || []) {
51
+ if (n.classification && !isFactual(n.classification)) {
52
+ out.push({ id: n.id, type: n.type, title: n.title || null, classification: n.classification, reason: `${n.classification} , confirm before treating as fact` });
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * Propose ThunderLang source from a structured graph, with a REVIEWABLE diff against the base.
60
+ * Accepts a graph, `{ graph }`, or the object `parseToStructured` returns. `base` may be the
61
+ * prior ThunderLang source (string) or its graph. Returns a proposal , it is never applied.
62
+ *
63
+ * Fidelity is honest: regenerating source from a graph does NOT preserve free-text comments;
64
+ * when the base carried comments, that is surfaced as a warning so the caller keeps the base
65
+ * source and applies structural edits rather than overwriting it.
66
+ */
67
+ export function proposeIntent(structured, { base = null } = {}) {
68
+ const graph = structured && structured.graph ? structured.graph : structured;
69
+ if (!graph || !Array.isArray(graph.nodes)) {
70
+ return { schema: SYNC_SCHEMA, ok: false, error: 'proposeIntent expects an intent graph (or { graph }).' };
71
+ }
72
+
73
+ const source = graphToSource(graph);
74
+
75
+ // Round-trip check: does the proposed source rebuild the same node set? Anything dropped is a
76
+ // fidelity ambiguity the caller should see (not silently lost).
77
+ const roundGraph = buildIntentGraph(parseIntent(source));
78
+ const roundIds = new Set(roundGraph.nodes.map((n) => n.id));
79
+ const lostNodes = graph.nodes.filter((n) => n.id && !roundIds.has(n.id)).map((n) => ({ id: n.id, type: n.type, title: n.title || null }));
80
+
81
+ const baseGraph = typeof base === 'string' ? buildIntentGraph(parseIntent(base)) : (base && base.graph) || base || null;
82
+ const diff = baseGraph && Array.isArray(baseGraph.nodes) ? diffGraphs(baseGraph, graph) : null;
83
+
84
+ const validation = validateGraph(graph);
85
+ const ambiguities = collectAmbiguities(graph);
86
+
87
+ const warnings = [];
88
+ if (hasComments(base)) warnings.push('Regenerating source from the graph does not preserve free-text comments in the base. To keep comments, use applyEdits(base, edits) (intent-patch-v1) to edit the source in place instead of replacing it.');
89
+ if (lostNodes.length) warnings.push(`${lostNodes.length} node(s) did not survive the graph->source round-trip; review before applying.`);
90
+ if (!validation.valid) warnings.push('Proposed graph has validation issues (see validation).');
91
+
92
+ return {
93
+ schema: SYNC_SCHEMA,
94
+ ok: true,
95
+ source,
96
+ diff, // reviewable: added/removed/changed nodes + relationships + invalidated approvals
97
+ ambiguities, // non-factual nodes: proposals, not guesses
98
+ lostNodes, // round-trip fidelity gaps
99
+ validation, // canonical-vocabulary + no-dangling check
100
+ warnings,
101
+ applied: false, // NEVER a silent rewrite; the caller reviews and applies
102
+ };
103
+ }
@@ -0,0 +1,59 @@
1
+ // First-class tests , make a .intent file SELF-VERIFYING. A `test <target>` block declares
2
+ // cases (for a decision) or scenarios (for a lifecycle) right next to the intent they check.
3
+ // `runTests` executes them through the deterministic Intent Runtime: no AI, no code, the
4
+ // spec proves itself. This is what lets intent be trustworthy , the behavior is asserted.
5
+
6
+ import { slug } from './parse.mjs';
7
+ import { evaluateDecision, simulateLifecycle } from './runtime.mjs';
8
+
9
+ export const TEST_SCHEMA = 'intent-test-v1';
10
+
11
+ // Coerce declared `given` values: numeric-looking -> number, true/false -> boolean, else
12
+ // string. A value wrapped in matching quotes is treated as a string with the quotes stripped,
13
+ // so `given severity "high"` and `given severity high` behave identically.
14
+ function coerce(given) {
15
+ const out = {};
16
+ for (const [k, raw] of Object.entries(given || {})) {
17
+ const v = String(raw).trim();
18
+ const quoted = v.length >= 2 && ((v[0] === '"' && v[v.length - 1] === '"') || (v[0] === "'" && v[v.length - 1] === "'"));
19
+ if (quoted) out[k] = v.slice(1, -1);
20
+ else if (v === 'true' || v === 'false') out[k] = v === 'true';
21
+ else if (v !== '' && !isNaN(Number(v))) out[k] = Number(v);
22
+ else out[k] = v;
23
+ }
24
+ return out;
25
+ }
26
+
27
+ /**
28
+ * Run every `test` block in an AST against its target decision or lifecycle.
29
+ * @returns {{schema, total, passed, failed, results, ok}}
30
+ */
31
+ export function runTests(ast) {
32
+ const decisions = ast.decisions || [];
33
+ const lifecycles = ast.lifecycles || [];
34
+ const results = [];
35
+
36
+ for (const t of ast.tests || []) {
37
+ const dec = decisions.find((d) => slug(d.name) === slug(t.name || ''));
38
+ const lc = lifecycles.find((l) => slug(l.name) === slug(t.name || ''));
39
+ for (const c of t.cases) {
40
+ const label = { target: t.name, case: c.name || `case ${results.length + 1}` };
41
+ if (dec) {
42
+ const inputs = coerce(c.given);
43
+ const run = evaluateDecision(dec, inputs);
44
+ const pass = c.expect == null || String(run.result) === String(c.expect);
45
+ results.push({ ...label, kind: 'decision', expected: c.expect, actual: run.result, pass, ...(run.ok ? {} : { note: 'a condition failed to evaluate' }) });
46
+ } else if (lc) {
47
+ const sim = simulateLifecycle(lc, c.events || []);
48
+ const passState = c.expect == null || sim.finalState === c.expect;
49
+ const passValid = c.expectValid == null || sim.valid === c.expectValid;
50
+ results.push({ ...label, kind: 'lifecycle', expected: c.expect, actual: sim.finalState, valid: sim.valid, expectValid: c.expectValid, pass: passState && passValid });
51
+ } else {
52
+ results.push({ ...label, kind: 'unknown', pass: false, error: `no decision or lifecycle named "${t.name}"` });
53
+ }
54
+ }
55
+ }
56
+
57
+ const passed = results.filter((r) => r.pass).length;
58
+ return { schema: TEST_SCHEMA, total: results.length, passed, failed: results.length - passed, results, ok: passed === results.length };
59
+ }