@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/patch.mjs
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// Structural source editing (intent-patch-v1) , apply field-level edits to EXISTING ThunderLang
|
|
2
|
+
// source, touching only the target lines so comments, formatting, ids, and untouched blocks stay
|
|
3
|
+
// byte-identical. This is the comment-preserving half of the Human <-> Structured sync: a PM
|
|
4
|
+
// changes a field and IL patches the source in place rather than regenerating it from the graph
|
|
5
|
+
// (which would drop `#` comments). Pure ESM, zero Node deps , browser-safe for Studio.
|
|
6
|
+
//
|
|
7
|
+
// applyEdits(source, edits) -> { schema, source, applied, skipped }
|
|
8
|
+
// edits: [{ op, ... }]
|
|
9
|
+
// { op: 'setField', field: 'goal'|'why'|'problem', value }
|
|
10
|
+
// { op: 'addGuarantee', statement, because?, verify? }
|
|
11
|
+
// { op: 'removeGuarantee', match } // substring match on the statement
|
|
12
|
+
// { op: 'addNever', statement }
|
|
13
|
+
// { op: 'removeNever', match }
|
|
14
|
+
// { op: 'addField', section: 'input'|'output', name, type }
|
|
15
|
+
// { op: 'removeField', section: 'input'|'output', name }
|
|
16
|
+
// { op: 'setFieldType', section: 'input'|'output', name, type }
|
|
17
|
+
// { op: 'addMetric', name, baseline?, target?, window? }
|
|
18
|
+
// { op: 'removeMetric', name }
|
|
19
|
+
// { op: 'setMetricField', name, field: 'baseline'|'target'|'window', value }
|
|
20
|
+
// { op: 'addOutcome', name, description? } { op: 'removeOutcome', name }
|
|
21
|
+
// { op: 'addRule', decision, name, when?, return? } { op: 'removeRule', decision, name }
|
|
22
|
+
// { op: 'setRule', decision, name, when?, return? } { op: 'setDefault', decision, return }
|
|
23
|
+
// Unsupported / not-found edits are returned in `skipped` with a reason , never applied blindly.
|
|
24
|
+
|
|
25
|
+
import { formatSource } from './format.mjs';
|
|
26
|
+
|
|
27
|
+
export const PATCH_SCHEMA = 'intent-patch-v1';
|
|
28
|
+
|
|
29
|
+
const isTopLevel = (line) => line.length > 0 && line[0] !== ' ' && line[0] !== '\t' && line.trim() !== '' && !line.trim().startsWith('#');
|
|
30
|
+
const firstWord = (line) => line.trim().split(/\s+/)[0];
|
|
31
|
+
const restOf = (line) => line.trim().slice(firstWord(line).length).trim();
|
|
32
|
+
|
|
33
|
+
// Top-level blocks with raw line ranges [start, end] (0-based, inclusive of body + trailing
|
|
34
|
+
// blank/comment lines up to the next top-level block).
|
|
35
|
+
function blocks(lines) {
|
|
36
|
+
const out = [];
|
|
37
|
+
let cur = null;
|
|
38
|
+
for (let i = 0; i < lines.length; i++) {
|
|
39
|
+
if (isTopLevel(lines[i])) {
|
|
40
|
+
if (cur) { cur.end = i - 1; out.push(cur); }
|
|
41
|
+
cur = { keyword: firstWord(lines[i]), header: lines[i].trim(), start: i, end: i };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (cur) { cur.end = lines.length - 1; out.push(cur); }
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Trim trailing blank lines off a block's range so inserts land tightly (keeps them as spacing).
|
|
49
|
+
function bodyEnd(lines, block) {
|
|
50
|
+
let e = block.end;
|
|
51
|
+
while (e > block.start && lines[e].trim() === '') e -= 1;
|
|
52
|
+
return e;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function setField(lines, field, value) {
|
|
56
|
+
const bs = blocks(lines);
|
|
57
|
+
const block = bs.find((b) => b.keyword === field);
|
|
58
|
+
const newBody = [`${field}`, ` ${value}`];
|
|
59
|
+
if (!block) {
|
|
60
|
+
// Insert after the mission header (and its title/for/persona lines if present), else at top.
|
|
61
|
+
const mission = bs.find((b) => b.keyword === 'mission');
|
|
62
|
+
const at = mission ? bodyEnd(lines, mission) + 1 : 0;
|
|
63
|
+
return { ok: true, lines: [...lines.slice(0, at), '', ...newBody, ...lines.slice(at)] };
|
|
64
|
+
}
|
|
65
|
+
const e = bodyEnd(lines, block);
|
|
66
|
+
return { ok: true, lines: [...lines.slice(0, block.start), ...newBody, ...lines.slice(e + 1)] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Insert a block after an anchor block (or at end), separated by one blank line.
|
|
70
|
+
function insertAfterAnchor(lines, body, anchorKeywords) {
|
|
71
|
+
const bs = blocks(lines);
|
|
72
|
+
let anchor = null;
|
|
73
|
+
for (const kw of anchorKeywords) { const found = bs.filter((b) => b.keyword === kw).slice(-1)[0]; if (found) { anchor = found; break; } }
|
|
74
|
+
const at = anchor ? bodyEnd(lines, anchor) + 1 : lines.length;
|
|
75
|
+
return { ok: true, lines: [...lines.slice(0, at), '', ...body, ...lines.slice(at)] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function addGuarantee(lines, { statement, because, verify }) {
|
|
79
|
+
const body = [`guarantee ${statement}`];
|
|
80
|
+
if (because) body.push(` because ${because}`);
|
|
81
|
+
if (verify) body.push(` verify ${verify}`);
|
|
82
|
+
return insertAfterAnchor(lines, body, ['guarantee', 'why', 'goal', 'mission']);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function addNever(lines, statement) {
|
|
86
|
+
return insertAfterAnchor(lines, [`never ${statement}`], ['never', 'guarantee', 'goal', 'mission']);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Remove a `guarantee <match>` / `never <match>` single block, or a matching line inside a
|
|
90
|
+
// plural `guarantees` / `never` block.
|
|
91
|
+
function removeRule(lines, keyword, pluralKeyword, match) {
|
|
92
|
+
const bs = blocks(lines);
|
|
93
|
+
const needle = String(match).toLowerCase();
|
|
94
|
+
// 1) single form: `guarantee <statement ...>`
|
|
95
|
+
const single = bs.find((b) => b.keyword === keyword && restOf(b.header).toLowerCase().includes(needle));
|
|
96
|
+
if (single) {
|
|
97
|
+
let s = single.start; let e = single.end;
|
|
98
|
+
// don't swallow a trailing blank that separates from the next block; trim trailing blanks
|
|
99
|
+
while (e > s && lines[e].trim() === '') e -= 1;
|
|
100
|
+
// also drop one leading blank line if present (keeps spacing tidy)
|
|
101
|
+
const out = [...lines.slice(0, s), ...lines.slice(e + 1)];
|
|
102
|
+
if (s > 0 && out[s - 1] !== undefined && out[s - 1].trim() === '' && (out[s] === undefined || out[s].trim() === '')) out.splice(s - 1, 1);
|
|
103
|
+
return { ok: true, lines: out };
|
|
104
|
+
}
|
|
105
|
+
// 2) plural block: `guarantees` with indented statement lines
|
|
106
|
+
const plural = bs.find((b) => b.keyword === pluralKeyword);
|
|
107
|
+
if (plural) {
|
|
108
|
+
for (let i = plural.start + 1; i <= plural.end; i++) {
|
|
109
|
+
if (lines[i].trim() && !lines[i].trim().startsWith('#') && lines[i].toLowerCase().includes(needle)) {
|
|
110
|
+
return { ok: true, lines: [...lines.slice(0, i), ...lines.slice(i + 1)] };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { ok: false, reason: `no ${keyword} matching "${match}" found` };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const indentOf = (line) => line.length - line.trimStart().length;
|
|
118
|
+
const fieldMatches = (line, name) => new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`).test(line.trim());
|
|
119
|
+
|
|
120
|
+
// Add a typed field to an `input`/`output` block (creating the block if absent).
|
|
121
|
+
function addField(lines, section, name, type) {
|
|
122
|
+
const block = blocks(lines).find((b) => b.keyword === section);
|
|
123
|
+
const fieldLine = ` ${name}: ${type}`;
|
|
124
|
+
if (!block) return insertAfterAnchor(lines, [section, fieldLine], ['output', 'input', 'why', 'goal', 'mission']);
|
|
125
|
+
for (let i = block.start + 1; i <= block.end; i++) {
|
|
126
|
+
if (lines[i].trim() && !lines[i].trim().startsWith('#') && fieldMatches(lines[i], name)) return { ok: false, reason: `field "${name}" already exists in ${section}` };
|
|
127
|
+
}
|
|
128
|
+
const e = bodyEnd(lines, block);
|
|
129
|
+
return { ok: true, lines: [...lines.slice(0, e + 1), fieldLine, ...lines.slice(e + 1)] };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Locate a field line + any deeper-indented child lines (modifiers/notes) that belong to it.
|
|
133
|
+
function fieldRange(lines, block, name) {
|
|
134
|
+
for (let i = block.start + 1; i <= block.end; i++) {
|
|
135
|
+
if (!lines[i].trim() || lines[i].trim().startsWith('#')) continue;
|
|
136
|
+
if (fieldMatches(lines[i], name)) {
|
|
137
|
+
const ind = indentOf(lines[i]);
|
|
138
|
+
let j = i + 1;
|
|
139
|
+
while (j <= block.end && lines[j].trim() !== '' && indentOf(lines[j]) > ind) j += 1;
|
|
140
|
+
return { start: i, end: j - 1 };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function removeField(lines, section, name) {
|
|
147
|
+
const block = blocks(lines).find((b) => b.keyword === section);
|
|
148
|
+
if (!block) return { ok: false, reason: `no ${section} block` };
|
|
149
|
+
const range = fieldRange(lines, block, name);
|
|
150
|
+
if (!range) return { ok: false, reason: `field "${name}" not found in ${section}` };
|
|
151
|
+
return { ok: true, lines: [...lines.slice(0, range.start), ...lines.slice(range.end + 1)] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function setFieldType(lines, section, name, type) {
|
|
155
|
+
const block = blocks(lines).find((b) => b.keyword === section);
|
|
156
|
+
if (!block) return { ok: false, reason: `no ${section} block` };
|
|
157
|
+
const range = fieldRange(lines, block, name);
|
|
158
|
+
if (!range) return { ok: false, reason: `field "${name}" not found in ${section}` };
|
|
159
|
+
const ind = lines[range.start].slice(0, indentOf(lines[range.start]));
|
|
160
|
+
const rebuilt = `${ind}${name}: ${type}`;
|
|
161
|
+
return { ok: true, lines: [...lines.slice(0, range.start), rebuilt, ...lines.slice(range.start + 1)] };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const findNamedBlock = (lines, keyword, name) => blocks(lines).find((b) => b.keyword === keyword && restOf(b.header) === name) || null;
|
|
165
|
+
|
|
166
|
+
function removeNamedBlock(lines, keyword, name) {
|
|
167
|
+
const b = findNamedBlock(lines, keyword, name);
|
|
168
|
+
if (!b) return { ok: false, reason: `no ${keyword} "${name}" found` };
|
|
169
|
+
let s = b.start; let e = b.end;
|
|
170
|
+
while (e > s && lines[e].trim() === '') e -= 1;
|
|
171
|
+
const out = [...lines.slice(0, s), ...lines.slice(e + 1)];
|
|
172
|
+
if (s > 0 && out[s - 1] !== undefined && out[s - 1].trim() === '' && (out[s] === undefined || out[s].trim() === '')) out.splice(s - 1, 1);
|
|
173
|
+
return { ok: true, lines: out };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function addMetric(lines, { name, baseline, target, window: win }) {
|
|
177
|
+
if (findNamedBlock(lines, 'metric', name)) return { ok: false, reason: `metric "${name}" already exists` };
|
|
178
|
+
const body = [`metric ${name}`];
|
|
179
|
+
if (baseline) body.push(` baseline ${baseline}`);
|
|
180
|
+
if (target) body.push(` target ${target}`);
|
|
181
|
+
if (win) body.push(` window ${win}`);
|
|
182
|
+
return insertAfterAnchor(lines, body, ['metric', 'outcome', 'why', 'goal', 'mission']);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Set baseline/target/window inside a `metric <name>` block (insert the line if absent).
|
|
186
|
+
function setMetricField(lines, name, field, value) {
|
|
187
|
+
const b = findNamedBlock(lines, 'metric', name);
|
|
188
|
+
if (!b) return { ok: false, reason: `no metric "${name}" found` };
|
|
189
|
+
for (let i = b.start + 1; i <= b.end; i++) {
|
|
190
|
+
if (lines[i].trim() && firstWord(lines[i]) === field) {
|
|
191
|
+
const ind = lines[i].slice(0, indentOf(lines[i]));
|
|
192
|
+
return { ok: true, lines: [...lines.slice(0, i), `${ind}${field} ${value}`, ...lines.slice(i + 1)] };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { ok: true, lines: [...lines.slice(0, b.start + 1), ` ${field} ${value}`, ...lines.slice(b.start + 1)] };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function addOutcome(lines, { name, description }) {
|
|
199
|
+
if (findNamedBlock(lines, 'outcome', name)) return { ok: false, reason: `outcome "${name}" already exists` };
|
|
200
|
+
const body = [`outcome ${name}`];
|
|
201
|
+
if (description) body.push(` "${description}"`);
|
|
202
|
+
return insertAfterAnchor(lines, body, ['outcome', 'why', 'goal', 'mission']);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The indent of a block's direct children (or a default when the block is empty).
|
|
206
|
+
function childIndentOf(lines, block, fallback = 2) {
|
|
207
|
+
for (let i = block.start + 1; i <= block.end; i++) {
|
|
208
|
+
if (lines[i].trim() && !lines[i].trim().startsWith('#')) return indentOf(lines[i]);
|
|
209
|
+
}
|
|
210
|
+
return fallback;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// A sub-block inside a parent block: its header line (at the parent's child indent) matches
|
|
214
|
+
// `matchHeader`, and it owns every following deeper-indented line. Returns { start, end, indent }.
|
|
215
|
+
function childSubBlock(lines, block, matchHeader) {
|
|
216
|
+
const indent = childIndentOf(lines, block);
|
|
217
|
+
for (let i = block.start + 1; i <= block.end; i++) {
|
|
218
|
+
if (lines[i].trim() && !lines[i].trim().startsWith('#') && indentOf(lines[i]) === indent && matchHeader(lines[i].trim())) {
|
|
219
|
+
let j = i + 1;
|
|
220
|
+
while (j <= block.end && (lines[j].trim() === '' || indentOf(lines[j]) > indent)) j += 1;
|
|
221
|
+
let e = j - 1;
|
|
222
|
+
while (e > i && lines[e].trim() === '') e -= 1;
|
|
223
|
+
return { start: i, end: e, indent };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const isRule = (name) => (h) => firstWord(h) === 'rule' && restOf(h) === name;
|
|
230
|
+
|
|
231
|
+
function addRule(lines, decision, name, when, ret) {
|
|
232
|
+
const d = findNamedBlock(lines, 'decision', decision);
|
|
233
|
+
if (!d) return { ok: false, reason: `no decision "${decision}" found` };
|
|
234
|
+
if (childSubBlock(lines, d, isRule(name))) return { ok: false, reason: `rule "${name}" already exists in ${decision}` };
|
|
235
|
+
const ci = childIndentOf(lines, d);
|
|
236
|
+
const body = [`${' '.repeat(ci)}rule ${name}`];
|
|
237
|
+
if (when) body.push(`${' '.repeat(ci + 2)}when ${when}`);
|
|
238
|
+
if (ret) body.push(`${' '.repeat(ci + 2)}return ${ret}`);
|
|
239
|
+
const def = childSubBlock(lines, d, (h) => firstWord(h) === 'default'); // keep default last
|
|
240
|
+
const at = def ? def.start : bodyEnd(lines, d) + 1;
|
|
241
|
+
return { ok: true, lines: [...lines.slice(0, at), ...body, ...lines.slice(at)] };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function removeDecisionRule(lines, decision, name) {
|
|
245
|
+
const d = findNamedBlock(lines, 'decision', decision);
|
|
246
|
+
if (!d) return { ok: false, reason: `no decision "${decision}" found` };
|
|
247
|
+
const sub = childSubBlock(lines, d, isRule(name));
|
|
248
|
+
if (!sub) return { ok: false, reason: `no rule "${name}" in ${decision}` };
|
|
249
|
+
return { ok: true, lines: [...lines.slice(0, sub.start), ...lines.slice(sub.end + 1)] };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Set a `keyword <value>` line inside a rule (or default) sub-block, inserting if absent.
|
|
253
|
+
function setSubLine(lines, decision, matchHeader, keyword, value, missingMsg) {
|
|
254
|
+
const d = findNamedBlock(lines, 'decision', decision);
|
|
255
|
+
if (!d) return { ok: false, reason: `no decision "${decision}" found` };
|
|
256
|
+
const sub = childSubBlock(lines, d, matchHeader);
|
|
257
|
+
if (!sub) return { ok: false, reason: missingMsg };
|
|
258
|
+
for (let i = sub.start + 1; i <= sub.end; i++) {
|
|
259
|
+
if (lines[i].trim() && firstWord(lines[i]) === keyword) {
|
|
260
|
+
const ind = lines[i].slice(0, indentOf(lines[i]));
|
|
261
|
+
return { ok: true, lines: [...lines.slice(0, i), `${ind}${keyword} ${value}`, ...lines.slice(i + 1)] };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { ok: true, lines: [...lines.slice(0, sub.start + 1), `${' '.repeat(sub.indent + 2)}${keyword} ${value}`, ...lines.slice(sub.start + 1)] };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function setDefault(lines, decision, ret) {
|
|
268
|
+
const d = findNamedBlock(lines, 'decision', decision);
|
|
269
|
+
if (!d) return { ok: false, reason: `no decision "${decision}" found` };
|
|
270
|
+
const def = childSubBlock(lines, d, (h) => firstWord(h) === 'default');
|
|
271
|
+
if (def) return setSubLine(lines, decision, (h) => firstWord(h) === 'default', 'return', ret, 'no default');
|
|
272
|
+
const ci = childIndentOf(lines, d);
|
|
273
|
+
const at = bodyEnd(lines, d) + 1;
|
|
274
|
+
return { ok: true, lines: [...lines.slice(0, at), `${' '.repeat(ci)}default`, `${' '.repeat(ci + 2)}return ${ret}`, ...lines.slice(at)] };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function applyOne(lines, edit) {
|
|
278
|
+
switch (edit && edit.op) {
|
|
279
|
+
case 'setField':
|
|
280
|
+
if (!['goal', 'why', 'problem'].includes(edit.field)) return { ok: false, reason: `setField only supports goal/why/problem, not "${edit.field}"` };
|
|
281
|
+
if (typeof edit.value !== 'string' || !edit.value.trim()) return { ok: false, reason: 'setField needs a non-empty value' };
|
|
282
|
+
return setField(lines, edit.field, edit.value.trim());
|
|
283
|
+
case 'addGuarantee':
|
|
284
|
+
if (!edit.statement) return { ok: false, reason: 'addGuarantee needs a statement' };
|
|
285
|
+
return addGuarantee(lines, edit);
|
|
286
|
+
case 'removeGuarantee':
|
|
287
|
+
if (!edit.match) return { ok: false, reason: 'removeGuarantee needs a match' };
|
|
288
|
+
return removeRule(lines, 'guarantee', 'guarantees', edit.match);
|
|
289
|
+
case 'addNever':
|
|
290
|
+
if (!edit.statement) return { ok: false, reason: 'addNever needs a statement' };
|
|
291
|
+
return addNever(lines, edit.statement);
|
|
292
|
+
case 'removeNever':
|
|
293
|
+
if (!edit.match) return { ok: false, reason: 'removeNever needs a match' };
|
|
294
|
+
return removeRule(lines, 'never', 'never', edit.match);
|
|
295
|
+
case 'addField':
|
|
296
|
+
if (!['input', 'output'].includes(edit.section)) return { ok: false, reason: `addField section must be input/output, not "${edit.section}"` };
|
|
297
|
+
if (!edit.name || !edit.type) return { ok: false, reason: 'addField needs name and type' };
|
|
298
|
+
return addField(lines, edit.section, edit.name, edit.type);
|
|
299
|
+
case 'removeField':
|
|
300
|
+
if (!['input', 'output'].includes(edit.section)) return { ok: false, reason: `removeField section must be input/output, not "${edit.section}"` };
|
|
301
|
+
if (!edit.name) return { ok: false, reason: 'removeField needs a name' };
|
|
302
|
+
return removeField(lines, edit.section, edit.name);
|
|
303
|
+
case 'setFieldType':
|
|
304
|
+
if (!['input', 'output'].includes(edit.section)) return { ok: false, reason: `setFieldType section must be input/output, not "${edit.section}"` };
|
|
305
|
+
if (!edit.name || !edit.type) return { ok: false, reason: 'setFieldType needs name and type' };
|
|
306
|
+
return setFieldType(lines, edit.section, edit.name, edit.type);
|
|
307
|
+
case 'addMetric':
|
|
308
|
+
if (!edit.name) return { ok: false, reason: 'addMetric needs a name' };
|
|
309
|
+
return addMetric(lines, edit);
|
|
310
|
+
case 'removeMetric':
|
|
311
|
+
if (!edit.name) return { ok: false, reason: 'removeMetric needs a name' };
|
|
312
|
+
return removeNamedBlock(lines, 'metric', edit.name);
|
|
313
|
+
case 'setMetricField':
|
|
314
|
+
if (!edit.name || !['baseline', 'target', 'window'].includes(edit.field)) return { ok: false, reason: 'setMetricField needs a name and field baseline/target/window' };
|
|
315
|
+
if (edit.value == null || edit.value === '') return { ok: false, reason: 'setMetricField needs a value' };
|
|
316
|
+
return setMetricField(lines, edit.name, edit.field, String(edit.value));
|
|
317
|
+
case 'addOutcome':
|
|
318
|
+
if (!edit.name) return { ok: false, reason: 'addOutcome needs a name' };
|
|
319
|
+
return addOutcome(lines, edit);
|
|
320
|
+
case 'removeOutcome':
|
|
321
|
+
if (!edit.name) return { ok: false, reason: 'removeOutcome needs a name' };
|
|
322
|
+
return removeNamedBlock(lines, 'outcome', edit.name);
|
|
323
|
+
case 'addRule':
|
|
324
|
+
if (!edit.decision || !edit.name) return { ok: false, reason: 'addRule needs decision and name' };
|
|
325
|
+
return addRule(lines, edit.decision, edit.name, edit.when, edit.return);
|
|
326
|
+
case 'removeRule':
|
|
327
|
+
if (!edit.decision || !edit.name) return { ok: false, reason: 'removeRule needs decision and name' };
|
|
328
|
+
return removeDecisionRule(lines, edit.decision, edit.name);
|
|
329
|
+
case 'setRule': {
|
|
330
|
+
if (!edit.decision || !edit.name) return { ok: false, reason: 'setRule needs decision and name' };
|
|
331
|
+
if (edit.when == null && edit.return == null) return { ok: false, reason: 'setRule needs when and/or return' };
|
|
332
|
+
let cur = lines; let any = false; let reason = '';
|
|
333
|
+
if (edit.when != null) { const r = setSubLine(cur, edit.decision, isRule(edit.name), 'when', edit.when, `no rule "${edit.name}" in ${edit.decision}`); if (r.ok) { cur = r.lines; any = true; } else reason = r.reason; }
|
|
334
|
+
if (edit.return != null) { const r = setSubLine(cur, edit.decision, isRule(edit.name), 'return', edit.return, `no rule "${edit.name}" in ${edit.decision}`); if (r.ok) { cur = r.lines; any = true; } else reason = r.reason; }
|
|
335
|
+
return any ? { ok: true, lines: cur } : { ok: false, reason: reason || 'setRule failed' };
|
|
336
|
+
}
|
|
337
|
+
case 'setDefault':
|
|
338
|
+
if (!edit.decision || edit.return == null) return { ok: false, reason: 'setDefault needs decision and return' };
|
|
339
|
+
return setDefault(lines, edit.decision, edit.return);
|
|
340
|
+
default:
|
|
341
|
+
return { ok: false, reason: `unknown op "${edit && edit.op}"` };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Apply structural edits to ThunderLang source, preserving comments and untouched blocks.
|
|
347
|
+
* When any edit applies, the result is normalized through the formatter , comments and content
|
|
348
|
+
* are preserved (whitespace-only), so block insertions never leave a stray blank line and the
|
|
349
|
+
* output is always canonically formatted. An empty/all-skipped edit list returns the input
|
|
350
|
+
* unchanged (byte-for-byte, apart from line-ending normalization).
|
|
351
|
+
*/
|
|
352
|
+
export function applyEdits(source, edits) {
|
|
353
|
+
const input = String(source ?? '');
|
|
354
|
+
let lines = input.split('\n');
|
|
355
|
+
const applied = []; const skipped = [];
|
|
356
|
+
for (const edit of edits || []) {
|
|
357
|
+
const r = applyOne(lines, edit);
|
|
358
|
+
if (r && r.ok) { lines = r.lines; applied.push(edit); }
|
|
359
|
+
else skipped.push({ edit, reason: (r && r.reason) || 'not applied' });
|
|
360
|
+
}
|
|
361
|
+
const joined = lines.join('\n');
|
|
362
|
+
const out = applied.length ? formatSource(joined) : joined;
|
|
363
|
+
return { schema: PATCH_SCHEMA, source: out, applied, skipped };
|
|
364
|
+
}
|
package/src/privacy.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Data purpose + privacy (founder Gap 6). A `data` element declares what a piece of
|
|
2
|
+
// data IS (classification), WHY it is held (purpose), HOW LONG (retention), and on WHAT
|
|
3
|
+
// LAWFUL BASIS. The privacy analysis enforces purpose limitation: sensitive data may not
|
|
4
|
+
// be held without a stated purpose, retention, and basis, and may not be exposed without a
|
|
5
|
+
// guard. Deterministic and pure , the same intent always produces the same findings.
|
|
6
|
+
|
|
7
|
+
export const PRIVACY_SCHEMA = 'intent-privacy-v1';
|
|
8
|
+
|
|
9
|
+
// Data classification (least -> most sensitive). pii/sensitive are the governed tiers.
|
|
10
|
+
export const DATA_CLASSIFICATIONS = ['public', 'internal', 'confidential', 'pii', 'sensitive'];
|
|
11
|
+
const GOVERNED = new Set(['pii', 'sensitive']);
|
|
12
|
+
|
|
13
|
+
// Lawful basis for holding personal data (GDPR Art. 6, normalized to snake_case tokens).
|
|
14
|
+
export const LAWFUL_BASES = ['consent', 'contract', 'legal_obligation', 'vital_interest', 'public_task', 'legitimate_interest'];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Static privacy analysis of an AST's declared data elements. Fires only on explicitly
|
|
18
|
+
* declared `data` blocks, so a mission with no data governance produces no findings.
|
|
19
|
+
* @returns {{code, path, message, severity}[]}
|
|
20
|
+
*/
|
|
21
|
+
export function analyzePrivacy(ast) {
|
|
22
|
+
const out = [];
|
|
23
|
+
const dataEls = ast.dataElements || [];
|
|
24
|
+
// The set of things a mission promises never to expose (e.g. "expose customer.ssn").
|
|
25
|
+
const neverExposed = new Set();
|
|
26
|
+
for (const n of ast.neverRules || []) {
|
|
27
|
+
const t = (n.statement || n.text || n.rule || '').toLowerCase();
|
|
28
|
+
if (/expos|leak|log|reveal|share/.test(t)) neverExposed.add(t);
|
|
29
|
+
}
|
|
30
|
+
const outputNames = new Set((ast.outputs || []).map((f) => (f.name || '').toLowerCase()));
|
|
31
|
+
|
|
32
|
+
for (const d of dataEls) {
|
|
33
|
+
const governed = GOVERNED.has((d.classification || '').toLowerCase());
|
|
34
|
+
|
|
35
|
+
if (d.classification && !DATA_CLASSIFICATIONS.includes(d.classification.toLowerCase())) {
|
|
36
|
+
out.push({ code: 'IL-DATA-004', path: d.path, severity: 'warning',
|
|
37
|
+
message: `Data "${d.path}" has an unknown classification "${d.classification}".` });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (governed) {
|
|
41
|
+
if (!d.purpose) out.push({ code: 'IL-DATA-001', path: d.path, severity: 'blocker',
|
|
42
|
+
message: `Sensitive data "${d.path}" (${d.classification}) is held with no stated purpose.` });
|
|
43
|
+
if (!d.retention) out.push({ code: 'IL-DATA-002', path: d.path, severity: 'warning',
|
|
44
|
+
message: `Sensitive data "${d.path}" (${d.classification}) has no retention rule.` });
|
|
45
|
+
if (!d.basis) out.push({ code: 'IL-DATA-003', path: d.path, severity: 'blocker',
|
|
46
|
+
message: `Sensitive data "${d.path}" (${d.classification}) declares no lawful basis.` });
|
|
47
|
+
else if (!LAWFUL_BASES.includes(d.basis.toLowerCase())) out.push({ code: 'IL-DATA-005', path: d.path, severity: 'warning',
|
|
48
|
+
message: `Sensitive data "${d.path}" declares an unrecognized lawful basis "${d.basis}".` });
|
|
49
|
+
|
|
50
|
+
// Purpose limitation: a sensitive element whose leaf name is an output field, with no
|
|
51
|
+
// never-expose guard covering it, is exposed without a governed guard.
|
|
52
|
+
const leaf = (d.path || '').split(/[.\s]/).pop().toLowerCase();
|
|
53
|
+
const isExposed = leaf && outputNames.has(leaf);
|
|
54
|
+
const guarded = [...neverExposed].some((t) => t.includes(leaf) || (d.path && t.includes(d.path.toLowerCase())));
|
|
55
|
+
if (isExposed && !guarded) out.push({ code: 'IL-DATA-006', path: d.path, severity: 'warning',
|
|
56
|
+
message: `Sensitive data "${d.path}" is returned as an output with no "never expose" guard.` });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
out.sort((a, b) => (a.code + a.path).localeCompare(b.code + b.path));
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Canonical proof envelope schema (intent-proof-v1) , the shape of a `.intent-proof.json`
|
|
2
|
+
// document. IL owns this: it is what the compiler emits (`buildProof`), what `intent verify`
|
|
3
|
+
// re-derives against, and the "shared envelope" siblings sign (STW) and re-verify (RM/OT)
|
|
4
|
+
// rather than each re-describing the proof shape. Pure ESM, ZERO Node deps , browser-safe so
|
|
5
|
+
// a signing service or a cert renderer can validate an envelope without a Node build.
|
|
6
|
+
//
|
|
7
|
+
// This DESCRIBES the existing emitted proof (additive, non-breaking); it does not change the
|
|
8
|
+
// bytes `buildProof` produces. `schemaVersion` is the content revision carried in the
|
|
9
|
+
// document; `PROOF_SCHEMA` is the canonical schema name this JSON Schema is published under.
|
|
10
|
+
|
|
11
|
+
export const PROOF_SCHEMA = 'intent-proof-v1';
|
|
12
|
+
|
|
13
|
+
// A claim (guarantee / never-rule) may be in exactly one of these states.
|
|
14
|
+
export const CLAIM_STATUSES = ['planned', 'needs_verification', 'verified', 'failed'];
|
|
15
|
+
// The proof as a whole is draft until a human approves (or rejects) it.
|
|
16
|
+
export const PROOF_STATUSES = ['draft', 'approved', 'rejected'];
|
|
17
|
+
|
|
18
|
+
/** The canonical JSON Schema (draft-07) for an intent-proof document. */
|
|
19
|
+
export function intentProofJsonSchema() {
|
|
20
|
+
const claim = {
|
|
21
|
+
type: 'object',
|
|
22
|
+
required: ['text', 'status'],
|
|
23
|
+
properties: {
|
|
24
|
+
id: { type: ['string', 'null'] },
|
|
25
|
+
text: { type: 'string' },
|
|
26
|
+
status: { enum: CLAIM_STATUSES },
|
|
27
|
+
evidence: { type: 'array', items: { type: 'string' } },
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
32
|
+
$id: `https://thunderlang.dev/schema/${PROOF_SCHEMA}.json`,
|
|
33
|
+
title: 'Intent Proof',
|
|
34
|
+
type: 'object',
|
|
35
|
+
required: [
|
|
36
|
+
'schemaVersion', 'missionName', 'sourceFile', 'sourceHash',
|
|
37
|
+
'guarantees', 'neverRules', 'verification', 'ai', 'humanApproval', 'proofStatus',
|
|
38
|
+
],
|
|
39
|
+
properties: {
|
|
40
|
+
schemaVersion: { type: 'string' },
|
|
41
|
+
sourceProduct: { type: 'string' },
|
|
42
|
+
missionName: { type: ['string', 'null'] },
|
|
43
|
+
sourceFile: { type: 'string' },
|
|
44
|
+
// sha256:<hex> , the source fingerprint `intent verify` re-checks for drift/tampering.
|
|
45
|
+
sourceHash: { type: 'string', pattern: '^sha256:[0-9a-f]{64}$' },
|
|
46
|
+
compilerVersion: { type: 'string' },
|
|
47
|
+
generatedAt: { type: ['string', 'null'] },
|
|
48
|
+
targetsRequested: { type: 'array', items: { type: 'string' } },
|
|
49
|
+
targetsGenerated: { type: 'array', items: { type: 'string' } },
|
|
50
|
+
guarantees: { type: 'array', items: claim },
|
|
51
|
+
neverRules: { type: 'array', items: claim },
|
|
52
|
+
errors: { type: 'array' },
|
|
53
|
+
examples: { type: 'array' },
|
|
54
|
+
verification: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
required: ['syntaxPassed', 'semanticPassed'],
|
|
57
|
+
properties: {
|
|
58
|
+
syntaxPassed: { type: 'boolean' },
|
|
59
|
+
semanticPassed: { type: 'boolean' },
|
|
60
|
+
targetsGenerated: { type: 'boolean' },
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
diagnostics: { type: 'array' },
|
|
64
|
+
ai: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
required: ['used'],
|
|
67
|
+
properties: { used: { type: 'boolean' } },
|
|
68
|
+
},
|
|
69
|
+
humanApproval: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
required: ['required', 'approved'],
|
|
72
|
+
properties: { required: { type: 'boolean' }, approved: { type: 'boolean' } },
|
|
73
|
+
},
|
|
74
|
+
proofStatus: { enum: PROOF_STATUSES },
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Deterministic, dependency-free structural validation , the same style the rest of the
|
|
80
|
+
// compiler uses (no JSON-Schema runtime). Returns { valid, errors: [{ path, message }] }.
|
|
81
|
+
// A signer/verifier calls this to reject a malformed envelope before trusting its claims.
|
|
82
|
+
export function validateProof(proof) {
|
|
83
|
+
const errors = [];
|
|
84
|
+
const err = (path, message) => errors.push({ path, message });
|
|
85
|
+
const isStr = (v) => typeof v === 'string';
|
|
86
|
+
const isBool = (v) => typeof v === 'boolean';
|
|
87
|
+
|
|
88
|
+
if (proof === null || typeof proof !== 'object' || Array.isArray(proof)) {
|
|
89
|
+
return { valid: false, errors: [{ path: '', message: 'proof must be an object' }] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const k of ['schemaVersion', 'sourceFile', 'sourceHash']) {
|
|
93
|
+
if (!isStr(proof[k])) err(k, `${k} must be a string`);
|
|
94
|
+
}
|
|
95
|
+
if (isStr(proof.sourceHash) && !/^sha256:[0-9a-f]{64}$/.test(proof.sourceHash)) {
|
|
96
|
+
err('sourceHash', 'sourceHash must be "sha256:<64 hex chars>"');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const key of ['guarantees', 'neverRules']) {
|
|
100
|
+
const list = proof[key];
|
|
101
|
+
if (!Array.isArray(list)) { err(key, `${key} must be an array`); continue; }
|
|
102
|
+
list.forEach((c, i) => {
|
|
103
|
+
if (c === null || typeof c !== 'object') { err(`${key}[${i}]`, 'claim must be an object'); return; }
|
|
104
|
+
if (!isStr(c.text)) err(`${key}[${i}].text`, 'claim text must be a string');
|
|
105
|
+
if (!CLAIM_STATUSES.includes(c.status)) err(`${key}[${i}].status`, `status must be one of ${CLAIM_STATUSES.join(', ')}`);
|
|
106
|
+
if (c.evidence !== undefined && !Array.isArray(c.evidence)) err(`${key}[${i}].evidence`, 'evidence must be an array');
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const v = proof.verification;
|
|
111
|
+
if (v === null || typeof v !== 'object') err('verification', 'verification must be an object');
|
|
112
|
+
else {
|
|
113
|
+
if (!isBool(v.syntaxPassed)) err('verification.syntaxPassed', 'must be a boolean');
|
|
114
|
+
if (!isBool(v.semanticPassed)) err('verification.semanticPassed', 'must be a boolean');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (proof.ai === null || typeof proof.ai !== 'object' || !isBool(proof.ai.used)) err('ai.used', 'ai.used must be a boolean');
|
|
118
|
+
|
|
119
|
+
const h = proof.humanApproval;
|
|
120
|
+
if (h === null || typeof h !== 'object') err('humanApproval', 'humanApproval must be an object');
|
|
121
|
+
else {
|
|
122
|
+
if (!isBool(h.required)) err('humanApproval.required', 'must be a boolean');
|
|
123
|
+
if (!isBool(h.approved)) err('humanApproval.approved', 'must be a boolean');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!PROOF_STATUSES.includes(proof.proofStatus)) err('proofStatus', `proofStatus must be one of ${PROOF_STATUSES.join(', ')}`);
|
|
127
|
+
|
|
128
|
+
return { valid: errors.length === 0, errors };
|
|
129
|
+
}
|
package/src/report.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Repo-wide intent health report (intent-report-v1). `intent check` gates a build pass/fail;
|
|
2
|
+
// this AGGREGATES across every .intent file into a triage view a team adopting ThunderLang can
|
|
3
|
+
// act on: how many missions, diagnostics by severity + area, the most common codes, and
|
|
4
|
+
// coverage signals (are guarantees verified? do missions have tests? are outcomes contracted?).
|
|
5
|
+
// Pure ESM, zero Node deps , the CLI reads the filesystem and passes sources in.
|
|
6
|
+
|
|
7
|
+
import { parseIntent } from './parse.mjs';
|
|
8
|
+
import { semanticDiagnostics } from './emit.mjs';
|
|
9
|
+
import { ALL_DIAGNOSTICS } from './intent-schema.mjs';
|
|
10
|
+
|
|
11
|
+
export const REPORT_SCHEMA = 'intent-report-v1';
|
|
12
|
+
|
|
13
|
+
const AREA_OF = new Map(ALL_DIAGNOSTICS.map((r) => [r.ruleId, r.area]));
|
|
14
|
+
const pct = (n, d) => (d > 0 ? Math.round((n / d) * 100) : null);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build an intent health report from source files.
|
|
18
|
+
* @param {Array<{file: string, source: string}>} files
|
|
19
|
+
*/
|
|
20
|
+
export function buildReport(files) {
|
|
21
|
+
const bySeverity = { blocker: 0, error: 0, warning: 0, info: 0 };
|
|
22
|
+
const byArea = {};
|
|
23
|
+
const byCode = {};
|
|
24
|
+
const perFile = [];
|
|
25
|
+
let missions = 0;
|
|
26
|
+
let guarantees = 0; let guaranteesVerified = 0;
|
|
27
|
+
let neverRules = 0; let neverVerified = 0;
|
|
28
|
+
let missionsWithTests = 0;
|
|
29
|
+
let outcomes = 0; let outcomeContracts = 0;
|
|
30
|
+
|
|
31
|
+
for (const { file, source } of files || []) {
|
|
32
|
+
const ast = parseIntent(String(source ?? ''));
|
|
33
|
+
const diags = semanticDiagnostics(ast);
|
|
34
|
+
if (ast.mission) missions += 1;
|
|
35
|
+
guarantees += (ast.guarantees || []).length;
|
|
36
|
+
guaranteesVerified += (ast.guarantees || []).filter((g) => g.verify && g.verify.length).length;
|
|
37
|
+
neverRules += (ast.neverRules || []).length;
|
|
38
|
+
neverVerified += (ast.neverRules || []).filter((n) => n.verify && n.verify.length).length;
|
|
39
|
+
if ((ast.tests || []).length) missionsWithTests += 1;
|
|
40
|
+
outcomes += (ast.outcomes || []).length;
|
|
41
|
+
outcomeContracts += (ast.outcomeContracts || []).length;
|
|
42
|
+
|
|
43
|
+
const counts = { blocker: 0, error: 0, warning: 0, info: 0 };
|
|
44
|
+
for (const d of diags) {
|
|
45
|
+
const sev = d.severity === 'blocker' ? 'blocker' : (d.level || 'info');
|
|
46
|
+
if (bySeverity[sev] === undefined) bySeverity[sev] = 0;
|
|
47
|
+
bySeverity[sev] += 1;
|
|
48
|
+
counts[sev] = (counts[sev] || 0) + 1;
|
|
49
|
+
if (d.code) {
|
|
50
|
+
byCode[d.code] = (byCode[d.code] || 0) + 1;
|
|
51
|
+
const area = AREA_OF.get(d.code) || 'other';
|
|
52
|
+
byArea[area] = (byArea[area] || 0) + 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
perFile.push({ file, mission: ast.mission || null, ...counts, ok: counts.error === 0 });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const topCodes = Object.entries(byCode).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
59
|
+
.slice(0, 10).map(([code, count]) => ({ code, count, area: AREA_OF.get(code) || 'other' }));
|
|
60
|
+
|
|
61
|
+
const totalDiagnostics = Object.values(bySeverity).reduce((a, b) => a + b, 0);
|
|
62
|
+
return {
|
|
63
|
+
schema: REPORT_SCHEMA,
|
|
64
|
+
ok: bySeverity.error === 0,
|
|
65
|
+
totals: { files: (files || []).length, missions, diagnostics: totalDiagnostics },
|
|
66
|
+
bySeverity,
|
|
67
|
+
byArea,
|
|
68
|
+
topCodes,
|
|
69
|
+
coverage: {
|
|
70
|
+
guarantees,
|
|
71
|
+
guaranteesVerified,
|
|
72
|
+
guaranteeVerifyRate: pct(guaranteesVerified, guarantees),
|
|
73
|
+
neverRules,
|
|
74
|
+
neverVerified,
|
|
75
|
+
neverVerifyRate: pct(neverVerified, neverRules),
|
|
76
|
+
missionsWithTests,
|
|
77
|
+
testCoverageRate: pct(missionsWithTests, missions),
|
|
78
|
+
outcomes,
|
|
79
|
+
outcomeContracts,
|
|
80
|
+
outcomeContractRate: pct(outcomeContracts, outcomes),
|
|
81
|
+
},
|
|
82
|
+
files: perFile.sort((a, b) => (b.error - a.error) || (b.warning - a.warning) || a.file.localeCompare(b.file)),
|
|
83
|
+
};
|
|
84
|
+
}
|