tslaws 0.0.0-stage → 0.3.2

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 (74) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +215 -2
  3. package/bin/tslaws.js +5 -0
  4. package/dist/adapters/angular.d.ts +42 -0
  5. package/dist/adapters/angular.js +40 -0
  6. package/dist/adapters/dom.d.ts +5 -0
  7. package/dist/adapters/dom.js +17 -0
  8. package/dist/adapters/element.d.ts +10 -0
  9. package/dist/adapters/element.js +28 -0
  10. package/dist/adapters/react.d.ts +24 -0
  11. package/dist/adapters/react.js +15 -0
  12. package/dist/api.d.ts +59 -0
  13. package/dist/api.js +103 -0
  14. package/dist/browser.d.ts +24 -0
  15. package/dist/browser.js +252 -0
  16. package/dist/browserApi.d.ts +62 -0
  17. package/dist/browserApi.js +139 -0
  18. package/dist/bundled.d.ts +17 -0
  19. package/dist/bundled.js +67 -0
  20. package/dist/check.d.ts +12 -0
  21. package/dist/check.js +269 -0
  22. package/dist/cli.d.ts +1 -0
  23. package/dist/cli.js +360 -0
  24. package/dist/compile.d.ts +15 -0
  25. package/dist/compile.js +142 -0
  26. package/dist/component.d.ts +37 -0
  27. package/dist/component.js +99 -0
  28. package/dist/coverage.d.ts +40 -0
  29. package/dist/coverage.js +237 -0
  30. package/dist/e2e.d.ts +46 -0
  31. package/dist/e2e.js +322 -0
  32. package/dist/e2eApi.d.ts +80 -0
  33. package/dist/e2eApi.js +140 -0
  34. package/dist/generators.d.ts +18 -0
  35. package/dist/generators.js +184 -0
  36. package/dist/hints.d.ts +3 -0
  37. package/dist/hints.js +37 -0
  38. package/dist/init.d.ts +23 -0
  39. package/dist/init.js +139 -0
  40. package/dist/lock.d.ts +16 -0
  41. package/dist/lock.js +40 -0
  42. package/dist/mcp.d.ts +30 -0
  43. package/dist/mcp.js +301 -0
  44. package/dist/mutate.d.ts +54 -0
  45. package/dist/mutate.js +293 -0
  46. package/dist/promote.d.ts +38 -0
  47. package/dist/promote.js +217 -0
  48. package/dist/property.d.ts +37 -0
  49. package/dist/property.js +172 -0
  50. package/dist/propertyWorker.d.ts +1 -0
  51. package/dist/propertyWorker.js +24 -0
  52. package/dist/queries.d.ts +37 -0
  53. package/dist/queries.js +297 -0
  54. package/dist/regressions.d.ts +31 -0
  55. package/dist/regressions.js +153 -0
  56. package/dist/reporters.d.ts +22 -0
  57. package/dist/reporters.js +160 -0
  58. package/dist/settings.d.ts +16 -0
  59. package/dist/settings.js +15 -0
  60. package/dist/smt.d.ts +33 -0
  61. package/dist/smt.js +396 -0
  62. package/dist/symbolic.d.ts +200 -0
  63. package/dist/symbolic.js +1727 -0
  64. package/dist/types.d.ts +77 -0
  65. package/dist/types.js +1 -0
  66. package/dist/ui.d.ts +16 -0
  67. package/dist/ui.js +326 -0
  68. package/dist/uiPage.d.ts +1 -0
  69. package/dist/uiPage.js +4 -0
  70. package/hooks/AGENTS.snippet.md +10 -0
  71. package/hooks/claude-hook.mjs +35 -0
  72. package/hooks/claude-settings.example.json +8 -0
  73. package/package.json +85 -4
  74. package/ui/index.html +1132 -0
package/dist/lock.js ADDED
@@ -0,0 +1,40 @@
1
+ // The tamper lock: a hash per law declaration. Laws are the human's side of the contract, so any change,
2
+ // addition or removal fails the check until a person runs `tslaws accept`.
3
+ import { createHash } from 'node:crypto';
4
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
+ export function lawKey(file, name) {
6
+ return `${file.replaceAll('\\', '/')}::${name}`;
7
+ }
8
+ export function lawHash(law) {
9
+ const normalized = `${law.proved ? 'proved ' : ''}${law.text.replace(/\s+/g, ' ')}`;
10
+ return createHash('sha256').update(normalized).digest('hex');
11
+ }
12
+ export function readLock(path) {
13
+ if (!existsSync(path))
14
+ return undefined;
15
+ const lock = JSON.parse(readFileSync(path, 'utf8'));
16
+ return { laws: lock.laws, exempt: lock.exempt ?? [] };
17
+ }
18
+ export function writeLock(path, laws, exempt = []) {
19
+ const entries = {};
20
+ for (const law of [...laws].sort((a, b) => lawKey(a.file, a.name).localeCompare(lawKey(b.file, b.name))))
21
+ entries[lawKey(law.file, law.name)] = lawHash(law);
22
+ writeFileSync(path, JSON.stringify({ version: 1, laws: entries, exempt: [...exempt].sort() }, null, 2) + '\n');
23
+ }
24
+ export function compareLock(lock, laws) {
25
+ const violations = [];
26
+ const seen = new Set();
27
+ for (const law of laws) {
28
+ const key = lawKey(law.file, law.name);
29
+ seen.add(key);
30
+ const locked = lock?.[key];
31
+ if (locked === undefined)
32
+ violations.push({ key, reason: 'added' });
33
+ else if (locked !== lawHash(law))
34
+ violations.push({ key, reason: 'changed' });
35
+ }
36
+ for (const key of Object.keys(lock ?? {}))
37
+ if (!seen.has(key))
38
+ violations.push({ key, reason: 'removed' });
39
+ return violations;
40
+ }
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ interface Tool {
2
+ name: string;
3
+ description: string;
4
+ inputSchema: Record<string, unknown>;
5
+ }
6
+ export declare const tools: Tool[];
7
+ export interface McpOptions {
8
+ cwd: string;
9
+ compiler: string;
10
+ }
11
+ /** The only files a tool may write. */
12
+ export declare function proposalPath(cwd: string, file: string): string;
13
+ export declare class Session {
14
+ private readonly last;
15
+ private readonly opts;
16
+ constructor(opts: McpOptions);
17
+ private remember;
18
+ private run;
19
+ call(name: string, args: Record<string, unknown>): Promise<unknown>;
20
+ }
21
+ /** The proposed file with the proposal's laws appended (same-named laws replaced) and its imports added once. */
22
+ export declare function mergeProposal(current: string, imports: string[], laws: {
23
+ name: string;
24
+ text: string;
25
+ }[]): string;
26
+ /** The reply line for one request line, or undefined for a notification. */
27
+ export declare function handleLine(session: Session, line: string): Promise<string | undefined>;
28
+ /** Serves MCP on stdin/stdout until stdin closes. Resolves the exit status. */
29
+ export declare function serveMcp(opts: McpOptions): Promise<number>;
30
+ export {};
package/dist/mcp.js ADDED
@@ -0,0 +1,301 @@
1
+ // `tslaws mcp`: a Model Context Protocol server over stdio (JSON-RPC 2.0, one message per line).
2
+ //
3
+ // Safety property: an agent that talks to this server can run checks and write PROPOSED laws, nothing else. No tool
4
+ // accepts laws, writes tsc-laws.lock or edits a `*.laws.ts` file that is not a `*.proposed.laws.ts`; the server sets
5
+ // TSLAWS_AGENT so nothing it spawns can run `accept` either. A person promotes proposals with `tslaws promote`.
6
+ // stdout is the protocol channel: this module writes nothing else to it.
7
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
8
+ import { createInterface } from 'node:readline';
9
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
10
+ import { check } from "./check.js";
11
+ import { readConfig } from "./coverage.js";
12
+ import { hintFor } from "./hints.js";
13
+ import { parseLawFile } from "./promote.js";
14
+ import { resolveSettings } from "./settings.js";
15
+ const ignoredDirs = new Set(['node_modules', '.tslaws', '.git']);
16
+ const passing = new Set(['proved', 'bounded', 'tested']);
17
+ const supportedVersions = ['2025-06-18', '2025-03-26', '2024-11-05'];
18
+ const defaultProposalFile = 'agent.proposed.laws.ts';
19
+ export const tools = [
20
+ {
21
+ name: 'tslaws_check',
22
+ description: 'Check the accepted laws (*.laws.ts) of the project, as `tslaws check --json`: each result has a status and a hint with the next action. Never changes any file the person owns.',
23
+ inputSchema: {
24
+ type: 'object',
25
+ properties: {
26
+ files: { type: 'array', items: { type: 'string' }, description: 'laws files to check; default: all' },
27
+ summary: { type: 'boolean', description: 'only the laws that do not pass, and the totals' },
28
+ },
29
+ },
30
+ },
31
+ {
32
+ name: 'tslaws_check_proposed',
33
+ description: 'Check the proposed laws (*.proposed.laws.ts), as `tslaws check --proposed`.',
34
+ inputSchema: { type: 'object', properties: {} },
35
+ },
36
+ {
37
+ name: 'tslaws_explain',
38
+ description: 'Show a law: its source, its status and hint from the last check in this session, and the next action.',
39
+ inputSchema: {
40
+ type: 'object',
41
+ properties: { law: { type: 'string', description: 'the law name' } },
42
+ required: ['law'],
43
+ },
44
+ },
45
+ {
46
+ name: 'tslaws_propose',
47
+ description: 'Write a law into a *.proposed.laws.ts file (appended; a law of the same name is replaced) and check it. Only proposed files can be written. A person promotes proposals with `tslaws promote`.',
48
+ inputSchema: {
49
+ type: 'object',
50
+ properties: {
51
+ name: { type: 'string', description: 'the law name' },
52
+ source: { type: 'string', description: 'the full `export law ...` declaration, with any imports it needs' },
53
+ file: {
54
+ type: 'string',
55
+ description: `project-relative *.proposed.laws.ts path; default ${defaultProposalFile}`,
56
+ },
57
+ },
58
+ required: ['name', 'source'],
59
+ },
60
+ },
61
+ ];
62
+ class ToolError extends Error {
63
+ }
64
+ function slash(path) {
65
+ return path.replace(/\\/g, '/');
66
+ }
67
+ function discover(cwd, proposed) {
68
+ const found = [];
69
+ const walk = (dir) => {
70
+ for (const entry of readdirSync(dir)) {
71
+ if (ignoredDirs.has(entry))
72
+ continue;
73
+ const full = join(dir, entry);
74
+ if (statSync(full).isDirectory())
75
+ walk(full);
76
+ else if (entry.endsWith('.laws.ts') && entry.endsWith('.proposed.laws.ts') === proposed)
77
+ found.push(full);
78
+ }
79
+ };
80
+ walk(cwd);
81
+ return found.map((f) => slash(relative(cwd, f))).sort();
82
+ }
83
+ /** A project-relative path inside the root that names an existing or new file; throws ToolError otherwise. */
84
+ function insideRoot(cwd, file) {
85
+ if (isAbsolute(file))
86
+ throw new ToolError(`'${file}' must be relative to the project root`);
87
+ const full = resolve(cwd, file);
88
+ const rel = relative(cwd, full);
89
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel))
90
+ throw new ToolError(`'${file}' is outside the project`);
91
+ if (rel.split(sep).some((part) => ignoredDirs.has(part)))
92
+ throw new ToolError(`'${file}' is in a reserved directory`);
93
+ // A symlinked directory must not lead out of the project either.
94
+ let probe = dirname(full);
95
+ while (!existsSync(probe))
96
+ probe = dirname(probe);
97
+ const back = relative(realpathSync(cwd), realpathSync(probe));
98
+ if (back.startsWith('..') || isAbsolute(back))
99
+ throw new ToolError(`'${file}' is outside the project`);
100
+ return full;
101
+ }
102
+ /** The only files a tool may write. */
103
+ export function proposalPath(cwd, file) {
104
+ if (!file.endsWith('.proposed.laws.ts') || file.endsWith('/.proposed.laws.ts'))
105
+ throw new ToolError(`'${file}' is not a *.proposed.laws.ts file; only proposed laws can be written`);
106
+ return insideRoot(cwd, file);
107
+ }
108
+ function options(opts, files) {
109
+ const settings = resolveSettings(new Map(), readConfig(opts.cwd));
110
+ return {
111
+ files,
112
+ cwd: opts.cwd,
113
+ runs: settings.runs,
114
+ compiler: opts.compiler,
115
+ lockFile: join(opts.cwd, 'tsc-laws.lock'),
116
+ noProve: false,
117
+ bound: settings.bound,
118
+ proofTimeoutMs: settings.proofTimeoutMs,
119
+ propertyTimeoutMs: settings.propertyTimeoutMs,
120
+ };
121
+ }
122
+ function withHints(report) {
123
+ return { ...report, results: report.results.map((r) => ({ ...r, hint: hintFor(r) })) };
124
+ }
125
+ function totals(report) {
126
+ const count = (status) => report.results.filter((r) => r.status === status).length;
127
+ return `${report.results.length} laws: ${count('proved')} proved, ${count('bounded')} bounded, ${count('tested')} tested, ${count('open')} open, ${count('failed')} failed, ${count('tampered')} tampered, ${count('error')} errors`;
128
+ }
129
+ function summarised(report) {
130
+ const full = withHints(report);
131
+ return { ...full, results: full.results.filter((r) => !passing.has(r.status)), totals: totals(report) };
132
+ }
133
+ export class Session {
134
+ last = new Map();
135
+ opts;
136
+ constructor(opts) {
137
+ this.opts = opts;
138
+ }
139
+ remember(report) {
140
+ for (const r of report.results)
141
+ this.last.set(r.name, r);
142
+ }
143
+ async run(files, enforceLock) {
144
+ if (files.length === 0)
145
+ throw new ToolError(`no ${enforceLock ? '' : 'proposed '}laws files found (looking for *.${enforceLock ? '' : 'proposed.'}laws.ts)`);
146
+ const report = await check(options(this.opts, files), enforceLock);
147
+ this.remember(report);
148
+ return report;
149
+ }
150
+ async call(name, args) {
151
+ const { cwd } = this.opts;
152
+ switch (name) {
153
+ case 'tslaws_check': {
154
+ const given = args.files;
155
+ if (given !== undefined && (!Array.isArray(given) || given.some((f) => typeof f !== 'string')))
156
+ throw new ToolError('files must be an array of strings');
157
+ const files = given === undefined || given.length === 0
158
+ ? discover(cwd, false)
159
+ : given.map((f) => slash(relative(cwd, insideRoot(cwd, f))));
160
+ const report = await this.run(files, true);
161
+ return args.summary === true ? summarised(report) : withHints(report);
162
+ }
163
+ case 'tslaws_check_proposed':
164
+ return withHints(await this.run(discover(cwd, true), false));
165
+ case 'tslaws_explain': {
166
+ if (typeof args.law !== 'string' || args.law === '')
167
+ throw new ToolError('law must be a law name');
168
+ const sources = [];
169
+ for (const file of [...discover(cwd, false), ...discover(cwd, true)]) {
170
+ const text = readFileSync(join(cwd, file), 'utf8');
171
+ for (const law of parseLawFile(text).laws)
172
+ if (law.name === args.law)
173
+ sources.push({ file, source: law.text, line: text.slice(0, law.start).split('\n').length });
174
+ }
175
+ const result = this.last.get(args.law);
176
+ if (sources.length === 0 && result === undefined)
177
+ throw new ToolError(`no law named '${args.law}'`);
178
+ return {
179
+ law: args.law,
180
+ declarations: sources,
181
+ lastResult: result === undefined ? null : { ...result, hint: hintFor(result) },
182
+ nextAction: result === undefined
183
+ ? 'Run tslaws_check (or tslaws_check_proposed for a proposal) to see its status.'
184
+ : (hintFor(result) ?? 'Nothing to do: the law is proved.'),
185
+ };
186
+ }
187
+ case 'tslaws_propose': {
188
+ if (typeof args.name !== 'string' || !/^[A-Za-z_$][\w$]*$/.test(args.name))
189
+ throw new ToolError('name must be an identifier');
190
+ if (typeof args.source !== 'string')
191
+ throw new ToolError('source must be a string');
192
+ const file = slash(typeof args.file === 'string' ? args.file : defaultProposalFile);
193
+ const target = proposalPath(cwd, file);
194
+ const proposal = parseLawFile(args.source);
195
+ const mine = proposal.laws.find((l) => l.name === args.name);
196
+ if (mine === undefined)
197
+ throw new ToolError(`source must contain \`export law ${args.name}\`…`);
198
+ let rest = args.source;
199
+ for (const part of [...proposal.laws, ...proposal.imports.map((text) => ({ text }))])
200
+ rest = rest.replace(part.text, '');
201
+ if (rest.trim() !== '')
202
+ throw new ToolError('source may contain only imports and law declarations');
203
+ const current = existsSync(target) ? readFileSync(target, 'utf8') : '';
204
+ mkdirSync(dirname(target), { recursive: true });
205
+ writeFileSync(target, mergeProposal(current, proposal.imports, proposal.laws));
206
+ const report = await this.run([file], false);
207
+ const names = new Set(proposal.laws.map((l) => l.name));
208
+ const results = report.results.filter((r) => names.has(r.name));
209
+ return { file, ...withHints({ ...report, results }) };
210
+ }
211
+ default:
212
+ throw new ToolError(`unknown tool '${name}'`);
213
+ }
214
+ }
215
+ }
216
+ /** The proposed file with the proposal's laws appended (same-named laws replaced) and its imports added once. */
217
+ export function mergeProposal(current, imports, laws) {
218
+ const parsed = parseLawFile(current);
219
+ let text = current;
220
+ for (const law of [...parsed.laws].reverse()) {
221
+ if (laws.some((l) => l.name === law.name))
222
+ text = text.slice(0, law.start) + text.slice(law.end);
223
+ }
224
+ const have = new Set(parseLawFile(text).imports.map((i) => i.replace(/\s+/g, ' ').trim()));
225
+ const missing = imports.filter((i) => !have.has(i.replace(/\s+/g, ' ').trim()));
226
+ const head = missing.length > 0 ? `${missing.join('\n')}\n` : '';
227
+ const body = `${text.trim() === '' ? '' : `${text.trim()}\n\n`}${laws.map((l) => l.text.trim()).join('\n\n')}\n`;
228
+ return `${head}${head !== '' && !body.startsWith('import') ? '\n' : ''}${body}`;
229
+ }
230
+ function reply(id, result) {
231
+ return `${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`;
232
+ }
233
+ function fail(id, code, message) {
234
+ return `${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`;
235
+ }
236
+ /** The reply line for one request line, or undefined for a notification. */
237
+ export async function handleLine(session, line) {
238
+ let message;
239
+ try {
240
+ message = JSON.parse(line);
241
+ }
242
+ catch {
243
+ return fail(null, -32700, 'parse error');
244
+ }
245
+ if (typeof message !== 'object' || message === null || typeof message.method !== 'string')
246
+ return message?.id === undefined ? undefined : fail(message.id, -32600, 'invalid request');
247
+ const id = message.id;
248
+ const params = message.params ?? {};
249
+ if (id === undefined)
250
+ return undefined; // notifications/initialized and friends
251
+ switch (message.method) {
252
+ case 'initialize': {
253
+ const asked = params.protocolVersion;
254
+ return reply(id, {
255
+ protocolVersion: typeof asked === 'string' && supportedVersions.includes(asked) ? asked : supportedVersions[0],
256
+ capabilities: { tools: {} },
257
+ serverInfo: { name: 'tslaws', version: '0.1.1' },
258
+ instructions: 'Run checks and propose laws. Accepting laws and writing the lock are done by a person with `tslaws accept` and `tslaws promote`; no tool here does either.',
259
+ });
260
+ }
261
+ case 'ping':
262
+ return reply(id, {});
263
+ case 'tools/list':
264
+ return reply(id, { tools });
265
+ case 'tools/call': {
266
+ const name = params.name;
267
+ if (typeof name !== 'string' || !tools.some((t) => t.name === name))
268
+ return fail(id, -32602, `unknown tool '${String(name)}'`);
269
+ const args = (params.arguments ?? {});
270
+ try {
271
+ const out = await session.call(name, args);
272
+ return reply(id, { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] });
273
+ }
274
+ catch (error) {
275
+ const text = error instanceof Error ? error.message : String(error);
276
+ return reply(id, { content: [{ type: 'text', text }], isError: true });
277
+ }
278
+ }
279
+ default:
280
+ return fail(id, -32601, `method not found: ${message.method}`);
281
+ }
282
+ }
283
+ /** Serves MCP on stdin/stdout until stdin closes. Resolves the exit status. */
284
+ export async function serveMcp(opts) {
285
+ process.env.TSLAWS_AGENT = '1';
286
+ const session = new Session(opts);
287
+ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
288
+ let chain = Promise.resolve();
289
+ lines.on('line', (line) => {
290
+ if (line.trim() === '')
291
+ return;
292
+ chain = chain.then(async () => {
293
+ const out = await handleLine(session, line);
294
+ if (out !== undefined)
295
+ process.stdout.write(out);
296
+ });
297
+ });
298
+ await new Promise((done) => lines.once('close', done));
299
+ await chain;
300
+ return 0;
301
+ }
@@ -0,0 +1,54 @@
1
+ import type { CheckOptions } from './types.ts';
2
+ export interface Mutant {
3
+ file: string;
4
+ line: number;
5
+ /** Offsets of the replaced text in the original file. */
6
+ start: number;
7
+ end: number;
8
+ replacement: string;
9
+ original: string;
10
+ mutated: string;
11
+ }
12
+ export interface MutantResult extends Mutant {
13
+ status: 'killed' | 'survived' | 'skipped';
14
+ /** Why a killed mutant died, or why one was skipped. */
15
+ reason?: string;
16
+ }
17
+ export interface MutateOptions {
18
+ cwd: string;
19
+ /** The laws files, as `tslaws check` takes them. */
20
+ lawFiles: string[];
21
+ /** Restrict mutation to these covered files; empty mutates every covered file. */
22
+ targets?: string[];
23
+ compiler: string;
24
+ max: number;
25
+ /** True when the person named law files: only the sources those laws reach are mutated. */
26
+ lawsNamed?: boolean;
27
+ seed?: number;
28
+ /** Run settings as `check` takes them; unset ones come from tslaws.json, then the mutate defaults (30 runs). */
29
+ runs?: number;
30
+ bound?: number;
31
+ proofTimeoutMs?: number;
32
+ propertyTimeoutMs?: number;
33
+ browser?: CheckOptions['browser'];
34
+ onProgress?: (line: string) => void;
35
+ }
36
+ export interface MutateReport {
37
+ results: MutantResult[];
38
+ killed: number;
39
+ survived: number;
40
+ skipped: number;
41
+ /** Percent of tried mutants killed; 100 when none were tried. */
42
+ score: number;
43
+ /** Set when the unmutated project's laws do not pass, so mutation testing means nothing. */
44
+ baselineError?: string;
45
+ ok: boolean;
46
+ }
47
+ /** The source files the laws cover: the `cover` globs, or every project module the laws import. */
48
+ export declare function mutableFiles(cwd: string, lawFiles: string[], reachOnly?: boolean): string[];
49
+ /** Every mutation of one file, in source order. */
50
+ export declare function mutantsOf(file: string, text: string): Mutant[];
51
+ /** At most `max` mutants: all of them when they fit, else a seeded sample kept in source order. */
52
+ export declare function sample<T>(all: T[], max: number, seed: number): T[];
53
+ export declare function mutate(options: MutateOptions): Promise<MutateReport>;
54
+ export declare function renderMutation(report: MutateReport): string;