@skillstech/thunderlang 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +375 -0
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/dist/core.cjs +6768 -0
- package/dist/index.cjs +9308 -0
- package/intent-graph.schema.json +687 -0
- package/package.json +84 -0
- package/src/ai-core.mjs +67 -0
- package/src/ai-events.mjs +56 -0
- package/src/ai.mjs +324 -0
- package/src/arch.mjs +55 -0
- package/src/atlas.mjs +118 -0
- package/src/changes.mjs +74 -0
- package/src/classification.mjs +36 -0
- package/src/cli.mjs +1534 -0
- package/src/codegen.mjs +214 -0
- package/src/compile.mjs +142 -0
- package/src/comprehension.mjs +130 -0
- package/src/conflict.mjs +0 -0
- package/src/core.d.ts +99 -0
- package/src/core.mjs +92 -0
- package/src/data-schema.mjs +137 -0
- package/src/decision.mjs +38 -0
- package/src/distributed.mjs +48 -0
- package/src/draft.mjs +101 -0
- package/src/drift.mjs +177 -0
- package/src/emit.mjs +519 -0
- package/src/exporters.mjs +245 -0
- package/src/expr.mjs +245 -0
- package/src/fable.mjs +110 -0
- package/src/focus.mjs +151 -0
- package/src/format.mjs +55 -0
- package/src/governance.mjs +100 -0
- package/src/graph-source.mjs +292 -0
- package/src/guard.mjs +105 -0
- package/src/guardian.mjs +98 -0
- package/src/hash.mjs +89 -0
- package/src/importers.mjs +194 -0
- package/src/index.d.ts +725 -0
- package/src/index.mjs +185 -0
- package/src/intellisense.mjs +210 -0
- package/src/intent-atlas.mjs +77 -0
- package/src/intent-graph.mjs +346 -0
- package/src/intent-ir.mjs +144 -0
- package/src/intent-schema.mjs +215 -0
- package/src/ledger.mjs +109 -0
- package/src/lifecycle.mjs +56 -0
- package/src/lift.mjs +693 -0
- package/src/lsp.mjs +152 -0
- package/src/mcp.mjs +158 -0
- package/src/migrate.mjs +118 -0
- package/src/outcome.mjs +93 -0
- package/src/parse.mjs +733 -0
- package/src/patch.mjs +364 -0
- package/src/privacy.mjs +61 -0
- package/src/proof-schema.mjs +129 -0
- package/src/report.mjs +84 -0
- package/src/runtime.mjs +96 -0
- package/src/sarif.mjs +88 -0
- package/src/scan-queries.mjs +97 -0
- package/src/scan.mjs +87 -0
- package/src/security.mjs +73 -0
- package/src/select.mjs +80 -0
- package/src/semantic-diff.mjs +125 -0
- package/src/simulate.mjs +106 -0
- package/src/style.mjs +250 -0
- package/src/sync.mjs +103 -0
- package/src/testing.mjs +59 -0
- package/src/twelve-factor.mjs +173 -0
- package/src/verify-diff.mjs +105 -0
- package/src/xml.mjs +87 -0
- package/syntaxes/intent.tmLanguage.json +55 -0
package/src/simulate.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/testing.mjs
ADDED
|
@@ -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
|
+
}
|