tslaws 0.3.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/dist/browser.js +70 -16
- package/dist/bundlerPlugins.d.ts +20 -0
- package/dist/bundlerPlugins.js +71 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +96 -27
- package/dist/coverage.d.ts +2 -0
- package/dist/coverage.js +5 -1
- package/dist/delegation.d.ts +68 -0
- package/dist/delegation.js +199 -0
- package/package.json +9 -7
package/README.md
CHANGED
|
@@ -159,13 +159,14 @@ See [docs/laws.md](../../docs/laws.md#the-dashboard).
|
|
|
159
159
|
|
|
160
160
|
`tslaws mutate [files...] [--max N] [--seed N] [--json]` mutation-tests the laws: it mutates the covered code, re-runs the laws against each mutant, and lists the survivors no law caught. It handles `.tsx`/`.jsx` and projects with a lock, and takes the run flags of `check`. See [docs/laws.md](../../docs/laws.md#mutation-testing).
|
|
161
161
|
|
|
162
|
+
- **Other source formats:** `tslaws.json` `bundlerPlugins` lists esbuild plugin modules (paths relative to the file) that teach the browser bundler formats like `.tsrx`. See [docs/laws.md](../../docs/laws.md#bundler-plugins).
|
|
162
163
|
- **Settings:** `tslaws.json` `runs`, `bound`, `proofTimeoutMs` and `propertyTimeoutMs` set the run defaults; a command line flag beats them. See [docs/laws.md](../../docs/laws.md#settings).
|
|
163
164
|
|
|
164
165
|
## Agents cannot move the goalposts
|
|
165
166
|
|
|
166
167
|
`tsc-laws.lock` holds a hash of every law declaration and the list of coverage exemptions. A change, an added law, a removed
|
|
167
168
|
law or a new `@tslaws-exempt` fails the check until a person runs `tslaws accept`, which refuses to run without a terminal,
|
|
168
|
-
under `CI`, or with `TSLAWS_AGENT` set
|
|
169
|
+
under `CI`, or with `TSLAWS_AGENT` set, unless a person has lent it for a limited time with `tslaws delegate grant`. Agents propose laws in `*.proposed.laws.ts` and try them with `tslaws check
|
|
169
170
|
--proposed` (no lock, no coverage gate); a person moves them into a `*.laws.ts` file and accepts.
|
|
170
171
|
|
|
171
172
|
`hooks/claude-hook.mjs` is a Claude Code hook that blocks `git commit` and sends an agent back to work when the check fails;
|
|
@@ -176,6 +177,9 @@ under `CI`, or with `TSLAWS_AGENT` set. Agents propose laws in `*.proposed.laws.
|
|
|
176
177
|
```
|
|
177
178
|
tslaws init [--force]
|
|
178
179
|
tslaws accept [files...]
|
|
180
|
+
tslaws delegate grant [--for 1h|8h|1d|7d] [--root DIR]
|
|
181
|
+
tslaws delegate revoke [--root DIR]
|
|
182
|
+
tslaws delegate status
|
|
179
183
|
tslaws check [files...] [--json] [--summary] [--watch] [--runs N] [--seed N] [--no-prove] [--bound N]
|
|
180
184
|
[--proof-timeout MS] [--property-timeout MS] [--proposed] [--compiler PATH]
|
|
181
185
|
```
|
|
@@ -183,7 +187,12 @@ tslaws check [files...] [--json] [--summary] [--watch] [--runs N] [--seed N] [--
|
|
|
183
187
|
- `init` scaffolds `tslaws.json`, the `.tslaws` ignore entry, the agent rules in `AGENTS.md`, the Claude Code hook in
|
|
184
188
|
`.claude/settings.json`, and a starter law if none exists. It never writes the lock; a person then runs `accept`.
|
|
185
189
|
- `accept` records the laws and the `@tslaws-exempt` functions in `tsc-laws.lock`. It refuses to run without a terminal,
|
|
186
|
-
under `CI`, or with `TSLAWS_AGENT` set.
|
|
190
|
+
under `CI`, or with `TSLAWS_AGENT` set, unless a live delegation covers the project (never under `CI`).
|
|
191
|
+
- `delegate grant` lends `accept` to agents for 1h, 8h (default), 1d or 7d (the most), for one project root. `delegate revoke`
|
|
192
|
+
withdraws it and `delegate status` lists grants; `grant` and `revoke` need a terminal and refuse under `CI` or
|
|
193
|
+
`TSLAWS_AGENT`. The grant lives in `~/.tslaws/delegation.json`, outside the repository (found through the operating
|
|
194
|
+
system's user record, not `HOME`; an agent running as the same user can still write that file). A delegated `accept` appends to `.tslaws/accept.log` and prints `DELEGATED ACCEPT`.
|
|
195
|
+
A grant is a person's decision, bounded in time, outside the repo, audited in `accept.log`. See [docs/laws.md](../../docs/laws.md#lending-accept-to-an-agent).
|
|
187
196
|
- `check` exits 0 only when every law is `proved`, `bounded` or `tested`, nothing is `uncovered`, the files compile and the
|
|
188
197
|
lock matches. `--json` adds a `hint` to each result, the concrete next action for an agent. `--summary` prints only the
|
|
189
198
|
results that do not pass, plus the totals. `--watch` re-runs on every `.ts` change.
|
package/dist/browser.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// each input is sent to a page where the compiled law and the code it checks run against a live DOM.
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { mkdirSync } from 'node:fs';
|
|
5
|
-
import { delimiter, join } from 'node:path';
|
|
5
|
+
import { delimiter, join, relative as pathRelative, resolve, sep } from 'node:path';
|
|
6
6
|
import { build as bundleWith } from 'esbuild';
|
|
7
|
+
import { explainBundleError, loadBundlerPlugins } from "./bundlerPlugins.js";
|
|
8
|
+
import { readConfig } from "./coverage.js";
|
|
7
9
|
import { chromium, firefox, webkit } from 'playwright-core';
|
|
8
10
|
import { toWire } from "./generators.js";
|
|
9
11
|
import { runProperty, summarise } from "./property.js";
|
|
@@ -156,21 +158,70 @@ function projectModulesPlugin(cwd) {
|
|
|
156
158
|
},
|
|
157
159
|
};
|
|
158
160
|
}
|
|
161
|
+
// The compiler writes only what it compiles to .tslaws/out, so a relative import of any other source file (a `.tsrx`
|
|
162
|
+
// template, a stylesheet, an image) would look for it there. Such an import resolves to the file beside the source.
|
|
163
|
+
function sourceFilesPlugin(cwd) {
|
|
164
|
+
const out = join(cwd, '.tslaws', 'out');
|
|
165
|
+
return {
|
|
166
|
+
name: 'tslaws-source-files',
|
|
167
|
+
setup(build) {
|
|
168
|
+
build.onResolve({ filter: /^\.\.?\// }, (args) => {
|
|
169
|
+
if (args.resolveDir !== out && !args.resolveDir.startsWith(out + sep))
|
|
170
|
+
return undefined;
|
|
171
|
+
const inOut = resolve(args.resolveDir, args.path);
|
|
172
|
+
if (existsSync(inOut))
|
|
173
|
+
return undefined;
|
|
174
|
+
const source = join(cwd, pathRelative(out, inOut));
|
|
175
|
+
return existsSync(source) && !source.endsWith('.js') ? { path: source } : undefined;
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const assetExtensions = [
|
|
181
|
+
'.png',
|
|
182
|
+
'.jpg',
|
|
183
|
+
'.jpeg',
|
|
184
|
+
'.gif',
|
|
185
|
+
'.webp',
|
|
186
|
+
'.avif',
|
|
187
|
+
'.svg',
|
|
188
|
+
'.ico',
|
|
189
|
+
'.woff',
|
|
190
|
+
'.woff2',
|
|
191
|
+
'.ttf',
|
|
192
|
+
'.otf',
|
|
193
|
+
'.eot',
|
|
194
|
+
];
|
|
195
|
+
/** The project's `bundlerPlugins` from tslaws.json come first, so they can claim a format before the built-in resolver. */
|
|
159
196
|
async function bundleOf(jsFile, cwd) {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
197
|
+
const projectPlugins = await loadBundlerPlugins(cwd, readConfig(cwd).bundlerPlugins);
|
|
198
|
+
try {
|
|
199
|
+
const result = await bundleWith({
|
|
200
|
+
plugins: [...projectPlugins, sourceFilesPlugin(cwd), projectModulesPlugin(cwd)],
|
|
201
|
+
entryPoints: [jsFile],
|
|
202
|
+
bundle: true,
|
|
203
|
+
write: false,
|
|
204
|
+
// Stylesheets a law imports come out as a separate .css file, which needs an output directory to be named.
|
|
205
|
+
outdir: join(cwd, '.tslaws', 'bundle'),
|
|
206
|
+
// Images and fonts become data URLs, so a url() in the CSS or an import in the JavaScript needs no file server.
|
|
207
|
+
loader: Object.fromEntries(assetExtensions.map((extension) => [extension, 'dataurl'])),
|
|
208
|
+
format: 'iife',
|
|
209
|
+
globalName: '__lawModule',
|
|
210
|
+
platform: 'browser',
|
|
211
|
+
target: 'es2022',
|
|
212
|
+
absWorkingDir: cwd,
|
|
213
|
+
logLevel: 'silent',
|
|
214
|
+
define: { 'process.env.NODE_ENV': '"development"', global: 'globalThis' },
|
|
215
|
+
});
|
|
216
|
+
const emitted = (extension) => result.outputFiles
|
|
217
|
+
.filter((file) => file.path.endsWith(extension))
|
|
218
|
+
.map((file) => file.text)
|
|
219
|
+
.join('\n');
|
|
220
|
+
return { js: emitted('.js'), css: emitted('.css') };
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
throw explainBundleError(error);
|
|
224
|
+
}
|
|
174
225
|
}
|
|
175
226
|
/** Runs the counterexample once more, leaves the DOM as it is, and photographs the page. Best effort. */
|
|
176
227
|
async function capture(page, law, args, options) {
|
|
@@ -187,7 +238,7 @@ async function capture(page, law, args, options) {
|
|
|
187
238
|
}
|
|
188
239
|
/** Property-tests one law in a page; `'timeout'` when it does not finish within `options.propertyTimeoutMs`. */
|
|
189
240
|
export async function runBrowserLaw(law, options) {
|
|
190
|
-
const code = await bundleOf(law.jsFile, options.cwd);
|
|
241
|
+
const { js: code, css } = await bundleOf(law.jsFile, options.cwd);
|
|
191
242
|
const page = await (await launch(resolveEngine(options.browser))).newPage();
|
|
192
243
|
// A watcher needs only the first few runs to linger; the rest should not eat the time limit.
|
|
193
244
|
let calls = 0;
|
|
@@ -216,6 +267,9 @@ export async function runBrowserLaw(law, options) {
|
|
|
216
267
|
: undefined;
|
|
217
268
|
try {
|
|
218
269
|
await page.setContent('<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>');
|
|
270
|
+
// The stylesheet goes in before the script runs, as a linked one would.
|
|
271
|
+
if (css !== '')
|
|
272
|
+
await page.addStyleTag({ content: css });
|
|
219
273
|
await page.addScriptTag({ content: code });
|
|
220
274
|
await page.addScriptTag({ content: harness });
|
|
221
275
|
const call = async (...args) => {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Plugin } from 'esbuild';
|
|
2
|
+
/** What a plugin factory receives. */
|
|
3
|
+
export interface BundlerPluginContext {
|
|
4
|
+
/** The project root: the directory holding tslaws.json. */
|
|
5
|
+
cwd: string;
|
|
6
|
+
}
|
|
7
|
+
export type BundlerPluginExport = Plugin | ((context: BundlerPluginContext) => Plugin | Promise<Plugin>);
|
|
8
|
+
/** The setting the messages name. */
|
|
9
|
+
export declare const bundlerPluginsSetting = "bundlerPlugins";
|
|
10
|
+
/**
|
|
11
|
+
* Loads each listed module (paths relative to `cwd`, where tslaws.json lives) with a dynamic import of its file URL.
|
|
12
|
+
* Throws one error naming the setting and the module when a module is missing, throws while loading or in its
|
|
13
|
+
* factory, or does not export a plugin, so the law file reports an `error` rather than the run crashing.
|
|
14
|
+
*/
|
|
15
|
+
export declare function loadBundlerPlugins(cwd: string, paths: readonly string[] | undefined): Promise<Plugin[]>;
|
|
16
|
+
/**
|
|
17
|
+
* Rewrites an esbuild failure into advice when the cause is a source format the bundler has no loader for (the usual
|
|
18
|
+
* sign that a `bundlerPlugins` entry is missing). Other messages pass through unchanged.
|
|
19
|
+
*/
|
|
20
|
+
export declare function explainBundleError(error: Error): Error;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
// Bundler plugins: a project teaches the browser bundler a source format (`.tsrx`, `.svelte`, ...) by listing esbuild
|
|
10
|
+
// plugin modules under `bundlerPlugins` in tslaws.json. Each module default-exports an esbuild Plugin, or a function
|
|
11
|
+
// (sync or async) that takes `{ cwd }` and returns one.
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
14
|
+
import { pathToFileURL } from 'node:url';
|
|
15
|
+
/** The setting the messages name. */
|
|
16
|
+
export const bundlerPluginsSetting = 'bundlerPlugins';
|
|
17
|
+
/**
|
|
18
|
+
* Loads each listed module (paths relative to `cwd`, where tslaws.json lives) with a dynamic import of its file URL.
|
|
19
|
+
* Throws one error naming the setting and the module when a module is missing, throws while loading or in its
|
|
20
|
+
* factory, or does not export a plugin, so the law file reports an `error` rather than the run crashing.
|
|
21
|
+
*/
|
|
22
|
+
export async function loadBundlerPlugins(cwd, paths) {
|
|
23
|
+
const plugins = [];
|
|
24
|
+
for (const path of paths ?? []) {
|
|
25
|
+
const file = isAbsolute(path) ? path : resolve(cwd, path);
|
|
26
|
+
const fail = (why) => {
|
|
27
|
+
throw new Error(`tslaws.json ${bundlerPluginsSetting}: cannot use "${path}" (${file}): ${why}`);
|
|
28
|
+
};
|
|
29
|
+
if (!existsSync(file))
|
|
30
|
+
fail('no such file; paths are relative to the directory of tslaws.json');
|
|
31
|
+
let exported;
|
|
32
|
+
try {
|
|
33
|
+
exported = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href))).default;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return fail(`it failed to load: ${error.message}`);
|
|
37
|
+
}
|
|
38
|
+
let plugin = exported;
|
|
39
|
+
if (typeof exported === 'function') {
|
|
40
|
+
try {
|
|
41
|
+
plugin = await exported({ cwd });
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
return fail(`its plugin function threw: ${error.message}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const candidate = plugin;
|
|
48
|
+
if (typeof candidate !== 'object' ||
|
|
49
|
+
candidate === null ||
|
|
50
|
+
typeof candidate.name !== 'string' ||
|
|
51
|
+
typeof candidate.setup !== 'function')
|
|
52
|
+
fail('its default export must be an esbuild plugin ({ name, setup }) or a function returning one');
|
|
53
|
+
plugins.push(plugin);
|
|
54
|
+
}
|
|
55
|
+
return plugins;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Rewrites an esbuild failure into advice when the cause is a source format the bundler has no loader for (the usual
|
|
59
|
+
* sign that a `bundlerPlugins` entry is missing). Other messages pass through unchanged.
|
|
60
|
+
*/
|
|
61
|
+
export function explainBundleError(error) {
|
|
62
|
+
const match = /No loader is configured for "(\.[^"]+)" files/.exec(error.message);
|
|
63
|
+
if (match === null)
|
|
64
|
+
return error;
|
|
65
|
+
const extension = match[1];
|
|
66
|
+
const advice = `the browser bundler cannot load "${extension}" files. Add a bundler plugin for that extension in tslaws.json ` +
|
|
67
|
+
`"${bundlerPluginsSetting}", for example { "${bundlerPluginsSetting}": ["./tools/${extension.slice(1)}-plugin.mjs"] }, ` +
|
|
68
|
+
`where the module default-exports an esbuild plugin whose onLoad({ filter: /\${extension}$/ }) returns the compiled ` +
|
|
69
|
+
`JavaScript (see "Bundler plugins" in docs/laws.md)`;
|
|
70
|
+
return new Error(`${advice}. esbuild said: ${error.message.split('\n')[0]}`);
|
|
71
|
+
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1 +1,13 @@
|
|
|
1
|
+
/** What a command needs from its caller. `home` is for unit tests; the CLI never sets it. */
|
|
2
|
+
export interface CommandContext {
|
|
3
|
+
isTTY: boolean;
|
|
4
|
+
env: NodeJS.ProcessEnv;
|
|
5
|
+
home?: string;
|
|
6
|
+
now?: Date;
|
|
7
|
+
out?: (text: string) => void;
|
|
8
|
+
err?: (text: string) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function acceptCommand(cwd: string, flags: Map<string, string | true>, files: string[], ctx: CommandContext): Promise<number>;
|
|
11
|
+
/** `tslaws delegate <action> [--for D] [--root DIR]`; `argv` is everything after `delegate`. */
|
|
12
|
+
export declare function runDelegateCommand(argv: string[], cwd: string, ctx: CommandContext): number;
|
|
1
13
|
export declare function main(args: string[]): Promise<number>;
|
package/dist/cli.js
CHANGED
|
@@ -15,13 +15,17 @@ import { readConfig } from "./coverage.js";
|
|
|
15
15
|
import { mutate, renderMutation } from "./mutate.js";
|
|
16
16
|
import { mutateDefaults, resolveSettings } from "./settings.js";
|
|
17
17
|
import { serveUi } from "./ui.js";
|
|
18
|
+
import { acceptGate, defaultDuration, describeGrants, fileHash, grantFor, logDelegatedAccept, parseDuration, personOnly, revokeFor, } from "./delegation.js";
|
|
18
19
|
import { writeLock } from "./lock.js";
|
|
19
20
|
const usage = `tslaws init [--force] scaffold tslaws.json, .gitignore, AGENTS.md, Claude Code hooks and a starter law (never writes the lock)
|
|
20
21
|
tslaws check [files...] [--reporter text|json|junit|github] [--json] [--summary] [--watch] [--runs N] [--seed N] [--no-prove] [--no-regressions] [--bound N] [--proof-timeout MS] [--property-timeout MS] [--proposed] [--compiler PATH] [--browser chromium|firefox|webkit]
|
|
21
22
|
tslaws ui [files...] [--port N] [--no-open] [--tab] open a dashboard of the laws in its own desktop window (--tab: a browser tab): tree, filters, search, source, live reruns on save
|
|
22
23
|
tslaws mutate [files...] [--max N] [--seed N] [--runs N] [--bound N] [--proof-timeout MS] [--property-timeout MS] [--browser ENGINE] [--json] mutation-test the laws: mutate covered code, report mutants no law kills
|
|
23
24
|
tslaws regressions [list|clear] show or delete the stored counterexamples (.tslaws/regressions); check replays them first, --no-regressions skips them
|
|
24
|
-
tslaws accept [files...] record the current laws in tsc-laws.lock (people only: needs a terminal)
|
|
25
|
+
tslaws accept [files...] record the current laws in tsc-laws.lock (people only: needs a terminal, or a live delegation; never under CI)
|
|
26
|
+
tslaws delegate grant [--for 1h|8h|1d|7d] [--root DIR] lend accept to an agent for a limited time (people only; default 8h, max 7d)
|
|
27
|
+
tslaws delegate revoke [--root DIR] withdraw the delegation for a project (people only)
|
|
28
|
+
tslaws delegate status list delegations and when they end
|
|
25
29
|
tslaws promote [file] [--all] move proposed laws (*.proposed.laws.ts) into the accepted *.laws.ts, asking per law (people only: needs a terminal; run accept afterwards)
|
|
26
30
|
tslaws mcp [--compiler PATH] serve check, check_proposed, explain and propose as Model Context Protocol tools on stdio (cannot accept or touch the lock)
|
|
27
31
|
|
|
@@ -65,6 +69,8 @@ function parse(args) {
|
|
|
65
69
|
'port',
|
|
66
70
|
'reporter',
|
|
67
71
|
'max',
|
|
72
|
+
'for',
|
|
73
|
+
'root',
|
|
68
74
|
]);
|
|
69
75
|
for (let i = 0; i < args.length; i += 1) {
|
|
70
76
|
const arg = args[i];
|
|
@@ -83,7 +89,8 @@ function parse(args) {
|
|
|
83
89
|
arg === 'mutate' ||
|
|
84
90
|
arg === 'regressions' ||
|
|
85
91
|
arg === 'mcp' ||
|
|
86
|
-
arg === 'promote'
|
|
92
|
+
arg === 'promote' ||
|
|
93
|
+
arg === 'delegate'))
|
|
87
94
|
command = arg;
|
|
88
95
|
else
|
|
89
96
|
files.push(arg);
|
|
@@ -229,6 +236,89 @@ async function watchChecks(cwd, flags, files) {
|
|
|
229
236
|
});
|
|
230
237
|
});
|
|
231
238
|
}
|
|
239
|
+
const stdoutWrite = (text) => void process.stdout.write(text);
|
|
240
|
+
const stderrWrite = (text) => void process.stderr.write(text);
|
|
241
|
+
export async function acceptCommand(cwd, flags, files, ctx) {
|
|
242
|
+
const out = ctx.out ?? stdoutWrite;
|
|
243
|
+
const err = ctx.err ?? stderrWrite;
|
|
244
|
+
const chosen = lawFiles(cwd, flags, files);
|
|
245
|
+
if (chosen.length === 0) {
|
|
246
|
+
err('no laws files found (looking for *.laws.ts)\n');
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
const gate = acceptGate({ isTTY: ctx.isTTY, env: ctx.env, root: cwd, now: ctx.now, home: ctx.home });
|
|
250
|
+
if (!gate.ok) {
|
|
251
|
+
err(`${gate.message}\n`);
|
|
252
|
+
return 1;
|
|
253
|
+
}
|
|
254
|
+
const opts = options(cwd, flags, chosen);
|
|
255
|
+
const laws = readManifest(opts.compiler, opts.files, cwd);
|
|
256
|
+
out(`${laws.length} laws in ${chosen.length} files:\n${laws.map((l) => ` ${l.file}:${l.line} ${l.name}`).join('\n')}\n`);
|
|
257
|
+
if (!gate.delegated) {
|
|
258
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
259
|
+
const answer = await rl.question('Type ACCEPT to write tsc-laws.lock: ');
|
|
260
|
+
rl.close();
|
|
261
|
+
if (answer.trim() !== 'ACCEPT')
|
|
262
|
+
return 1;
|
|
263
|
+
}
|
|
264
|
+
const before = fileHash(opts.lockFile);
|
|
265
|
+
const exempt = exemptionsOf(opts);
|
|
266
|
+
if (exempt.length > 0)
|
|
267
|
+
out(`exempt from coverage:\n${exempt.map((e) => ` ${e}`).join('\n')}\n`);
|
|
268
|
+
writeLock(opts.lockFile, laws, exempt);
|
|
269
|
+
out('tsc-laws.lock written\n');
|
|
270
|
+
if (gate.delegated) {
|
|
271
|
+
logDelegatedAccept(cwd, gate.grant, before, fileHash(opts.lockFile), ctx.now ?? new Date());
|
|
272
|
+
out(`DELEGATED ACCEPT: a person lent this act until ${gate.grant.expires}; recorded in .tslaws/accept.log\n`);
|
|
273
|
+
}
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
/** `tslaws delegate <action> [--for D] [--root DIR]`; `argv` is everything after `delegate`. */
|
|
277
|
+
export function runDelegateCommand(argv, cwd, ctx) {
|
|
278
|
+
const { flags, files } = parse(['delegate', ...argv]);
|
|
279
|
+
return delegateCommand(cwd, files[0], flags, ctx);
|
|
280
|
+
}
|
|
281
|
+
function delegateCommand(cwd, action, flags, ctx) {
|
|
282
|
+
const out = ctx.out ?? stdoutWrite;
|
|
283
|
+
const err = ctx.err ?? stderrWrite;
|
|
284
|
+
const now = ctx.now ?? new Date();
|
|
285
|
+
if (action === 'status') {
|
|
286
|
+
out(`${describeGrants(now, ctx.home).join('\n')}\n`);
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
if (action !== 'grant' && action !== 'revoke') {
|
|
290
|
+
err('usage: tslaws delegate grant|revoke|status\n');
|
|
291
|
+
return 1;
|
|
292
|
+
}
|
|
293
|
+
if (!personOnly({ isTTY: ctx.isTTY, env: ctx.env })) {
|
|
294
|
+
err(`delegate ${action} is a person's decision and needs a terminal; it will not run here\n`);
|
|
295
|
+
return 1;
|
|
296
|
+
}
|
|
297
|
+
const rootFlag = flags.get('root');
|
|
298
|
+
const target = rootFlag === undefined || rootFlag === true ? cwd : resolve(cwd, rootFlag);
|
|
299
|
+
if (action === 'revoke') {
|
|
300
|
+
const removed = revokeFor(target, ctx.home);
|
|
301
|
+
if (typeof removed === 'string') {
|
|
302
|
+
err(`${removed}\n`);
|
|
303
|
+
return 1;
|
|
304
|
+
}
|
|
305
|
+
out(`${removed} delegation${removed === 1 ? '' : 's'} revoked\n`);
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
308
|
+
const lengthFlag = flags.get('for');
|
|
309
|
+
const ms = parseDuration(lengthFlag === undefined ? defaultDuration : String(lengthFlag));
|
|
310
|
+
if (ms === undefined) {
|
|
311
|
+
err('--for takes 1h, 8h, 1d or 7d (the most is 7d)\n');
|
|
312
|
+
return 1;
|
|
313
|
+
}
|
|
314
|
+
const granted = grantFor(target, ms, now, ctx.home);
|
|
315
|
+
if (typeof granted === 'string') {
|
|
316
|
+
err(`${granted}\n`);
|
|
317
|
+
return 1;
|
|
318
|
+
}
|
|
319
|
+
out(`accept is lent to agents for ${granted.root} until ${granted.expires}\n`);
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
232
322
|
export async function main(args) {
|
|
233
323
|
if (args.includes('--help') || args.includes('-h')) {
|
|
234
324
|
process.stdout.write(`${usage}\n`);
|
|
@@ -240,31 +330,10 @@ export async function main(args) {
|
|
|
240
330
|
process.stdout.write(`${init({ cwd, force: flags.has('force') }).join('\n')}\n\n${nextStep}\n`);
|
|
241
331
|
return 0;
|
|
242
332
|
}
|
|
243
|
-
if (command === 'accept')
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
return 1;
|
|
248
|
-
}
|
|
249
|
-
if (!process.stdin.isTTY || process.env.TSLAWS_AGENT !== undefined || process.env.CI !== undefined) {
|
|
250
|
-
process.stderr.write('accept records laws as correct and needs a person at a terminal; it will not run here\n');
|
|
251
|
-
return 1;
|
|
252
|
-
}
|
|
253
|
-
const opts = options(cwd, flags, chosen);
|
|
254
|
-
const laws = readManifest(opts.compiler, opts.files, cwd);
|
|
255
|
-
process.stdout.write(`${laws.length} laws in ${chosen.length} files:\n${laws.map((l) => ` ${l.file}:${l.line} ${l.name}`).join('\n')}\n`);
|
|
256
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
257
|
-
const answer = await rl.question('Type ACCEPT to write tsc-laws.lock: ');
|
|
258
|
-
rl.close();
|
|
259
|
-
if (answer.trim() !== 'ACCEPT')
|
|
260
|
-
return 1;
|
|
261
|
-
const exempt = exemptionsOf(opts);
|
|
262
|
-
if (exempt.length > 0)
|
|
263
|
-
process.stdout.write(`exempt from coverage:\n${exempt.map((e) => ` ${e}`).join('\n')}\n`);
|
|
264
|
-
writeLock(opts.lockFile, laws, exempt);
|
|
265
|
-
process.stdout.write('tsc-laws.lock written\n');
|
|
266
|
-
return 0;
|
|
267
|
-
}
|
|
333
|
+
if (command === 'accept')
|
|
334
|
+
return acceptCommand(cwd, flags, files, { isTTY: process.stdin.isTTY === true, env: process.env });
|
|
335
|
+
if (command === 'delegate')
|
|
336
|
+
return delegateCommand(cwd, files[0], flags, { isTTY: process.stdin.isTTY === true, env: process.env });
|
|
268
337
|
if (command === 'mcp')
|
|
269
338
|
return serveMcp({ cwd, compiler: options(cwd, flags, []).compiler });
|
|
270
339
|
if (command === 'promote') {
|
package/dist/coverage.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface CoverageConfig {
|
|
|
6
6
|
compilerOptions?: Record<string, unknown>;
|
|
7
7
|
/** End-to-end laws: the app to drive; see `parseE2eConfig`. */
|
|
8
8
|
e2e?: E2eConfig;
|
|
9
|
+
/** esbuild plugin modules (paths relative to tslaws.json) that teach the browser bundler new source formats. */
|
|
10
|
+
bundlerPlugins?: string[];
|
|
9
11
|
/** Run settings; a command line flag beats them and they beat the built-in default. */
|
|
10
12
|
runs?: number;
|
|
11
13
|
propertyTimeoutMs?: number;
|
package/dist/coverage.js
CHANGED
|
@@ -9,7 +9,7 @@ import { dirname, join, relative, resolve } from 'node:path';
|
|
|
9
9
|
import ts from 'typescript';
|
|
10
10
|
import { parseE2eConfig } from "./e2e.js";
|
|
11
11
|
const settingKeys = ['runs', 'propertyTimeoutMs', 'proofTimeoutMs', 'bound'];
|
|
12
|
-
const configKeys = ['cover', 'compilerOptions', 'e2e', ...settingKeys];
|
|
12
|
+
const configKeys = ['cover', 'compilerOptions', 'e2e', 'bundlerPlugins', ...settingKeys];
|
|
13
13
|
/** Validates the top level of tslaws.json: known keys only, and the run settings are integers of at least 1. */
|
|
14
14
|
export function parseConfig(raw) {
|
|
15
15
|
const fail = (message) => {
|
|
@@ -27,6 +27,10 @@ export function parseConfig(raw) {
|
|
|
27
27
|
if (value !== undefined && (typeof value !== 'number' || !Number.isInteger(value) || value < 1))
|
|
28
28
|
fail(`"${key}" must be an integer of at least 1, got ${JSON.stringify(value)}`);
|
|
29
29
|
}
|
|
30
|
+
const plugins = object.bundlerPlugins;
|
|
31
|
+
if (plugins !== undefined &&
|
|
32
|
+
(!Array.isArray(plugins) || plugins.some((entry) => typeof entry !== 'string' || entry === '')))
|
|
33
|
+
fail(`"bundlerPlugins" must be an array of module paths, got ${JSON.stringify(plugins)}`);
|
|
30
34
|
return object;
|
|
31
35
|
}
|
|
32
36
|
export function readConfig(cwd) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export interface Grant {
|
|
2
|
+
root: string;
|
|
3
|
+
expires: string;
|
|
4
|
+
note?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare const defaultDuration = "8h";
|
|
7
|
+
/**
|
|
8
|
+
* The person's home directory from the operating system's user record (getpwuid on POSIX, the profile directory of the
|
|
9
|
+
* user token on Windows). HOME and USERPROFILE are ignored on purpose. An empty string means it cannot be found.
|
|
10
|
+
*/
|
|
11
|
+
export declare function userHome(): string;
|
|
12
|
+
/** The grants file under a home directory: the real one by default, a temp directory in unit tests. */
|
|
13
|
+
export declare function delegationFile(home?: string): string;
|
|
14
|
+
export declare function parseDuration(text: string): number | undefined;
|
|
15
|
+
/** The real path of a directory; undefined when it is missing. */
|
|
16
|
+
export declare function realRoot(dir: string): string | undefined;
|
|
17
|
+
export type Grants = {
|
|
18
|
+
ok: true;
|
|
19
|
+
grants: Grant[];
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
reason: string;
|
|
23
|
+
};
|
|
24
|
+
/** Reads the grants. A missing file is an empty list; an unsafe or malformed one is an error. */
|
|
25
|
+
export declare function readGrants(home?: string): Grants;
|
|
26
|
+
export type Verdict = {
|
|
27
|
+
ok: true;
|
|
28
|
+
grant: Grant;
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
reason: string;
|
|
32
|
+
};
|
|
33
|
+
/** The live grant for a project root. Real paths are compared, and a grant naming a symlink never matches. */
|
|
34
|
+
export declare function findGrant(root: string, now: Date, home?: string): Verdict;
|
|
35
|
+
export interface GateInput {
|
|
36
|
+
isTTY: boolean;
|
|
37
|
+
env: NodeJS.ProcessEnv;
|
|
38
|
+
root: string;
|
|
39
|
+
now?: Date;
|
|
40
|
+
/** Home directory holding the grants; unit tests pass a temp directory. */
|
|
41
|
+
home?: string;
|
|
42
|
+
}
|
|
43
|
+
export type Gate = {
|
|
44
|
+
ok: true;
|
|
45
|
+
delegated: false;
|
|
46
|
+
} | {
|
|
47
|
+
ok: true;
|
|
48
|
+
delegated: true;
|
|
49
|
+
grant: Grant;
|
|
50
|
+
} | {
|
|
51
|
+
ok: false;
|
|
52
|
+
message: string;
|
|
53
|
+
};
|
|
54
|
+
/** Decides whether `accept` may run: a person at a terminal, else a live grant. CI never. */
|
|
55
|
+
export declare function acceptGate(input: GateInput): Gate;
|
|
56
|
+
/** The gate for acts only a person may do: a terminal, no agent marker, no CI. */
|
|
57
|
+
export declare function personOnly(input: {
|
|
58
|
+
isTTY: boolean;
|
|
59
|
+
env: NodeJS.ProcessEnv;
|
|
60
|
+
}): boolean;
|
|
61
|
+
/** Records a grant for a project; returns it, or the reason it was refused. */
|
|
62
|
+
export declare function grantFor(root: string, ms: number, now: Date, home?: string): Grant | string;
|
|
63
|
+
/** Removes the grants for a project; returns how many, or the reason it was refused. */
|
|
64
|
+
export declare function revokeFor(root: string, home?: string): number | string;
|
|
65
|
+
export declare function describeGrants(now: Date, home?: string): string[];
|
|
66
|
+
export declare function fileHash(path: string): string;
|
|
67
|
+
/** Appends the audit line for a delegated accept to `<project>/.tslaws/accept.log`. */
|
|
68
|
+
export declare function logDelegatedAccept(root: string, grant: Grant, before: string, after: string, now: Date): void;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// A person's time-limited delegation of `tslaws accept` to an agent. The grant lives in the person's home
|
|
2
|
+
// directory, outside every project, so nothing an agent can edit in a repository can create one.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync, } from 'node:fs';
|
|
5
|
+
import { userInfo } from 'node:os';
|
|
6
|
+
import { join, resolve, sep } from 'node:path';
|
|
7
|
+
export const defaultDuration = '8h';
|
|
8
|
+
const hour = 3_600_000;
|
|
9
|
+
const durations = { '1h': hour, '8h': 8 * hour, '1d': 24 * hour, '7d': 168 * hour };
|
|
10
|
+
const regrant = 'run `tslaws delegate grant` at a terminal to lend accept for a limited time';
|
|
11
|
+
/**
|
|
12
|
+
* The person's home directory from the operating system's user record (getpwuid on POSIX, the profile directory of the
|
|
13
|
+
* user token on Windows). HOME and USERPROFILE are ignored on purpose. An empty string means it cannot be found.
|
|
14
|
+
*/
|
|
15
|
+
export function userHome() {
|
|
16
|
+
try {
|
|
17
|
+
return userInfo().homedir;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** The grants file under a home directory: the real one by default, a temp directory in unit tests. */
|
|
24
|
+
export function delegationFile(home = userHome()) {
|
|
25
|
+
return join(home, '.tslaws', 'delegation.json');
|
|
26
|
+
}
|
|
27
|
+
export function parseDuration(text) {
|
|
28
|
+
return Object.hasOwn(durations, text) ? durations[text] : undefined;
|
|
29
|
+
}
|
|
30
|
+
/** The real path of a directory; undefined when it is missing. */
|
|
31
|
+
export function realRoot(dir) {
|
|
32
|
+
try {
|
|
33
|
+
return realpathSync.native(resolve(dir));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function same(a, b) {
|
|
40
|
+
return process.platform === 'win32' || process.platform === 'darwin' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
41
|
+
}
|
|
42
|
+
/** The grants directory must resolve inside the real home directory, not through a symlink elsewhere. */
|
|
43
|
+
function grantsDirProblem(home) {
|
|
44
|
+
if (home === '')
|
|
45
|
+
return 'the home directory is unknown (the user database gave none)';
|
|
46
|
+
const dir = join(home, '.tslaws');
|
|
47
|
+
if (!existsSync(dir))
|
|
48
|
+
return undefined;
|
|
49
|
+
const base = realRoot(home);
|
|
50
|
+
const real = realRoot(dir);
|
|
51
|
+
if (base === undefined || real === undefined)
|
|
52
|
+
return `${dir} cannot be resolved`;
|
|
53
|
+
const inside = real.length > base.length && same(real.slice(0, base.length), base) && real[base.length] === sep;
|
|
54
|
+
return inside ? undefined : `${dir} resolves outside the home directory (${real})`;
|
|
55
|
+
}
|
|
56
|
+
/** A home directory that is the project root, or inside it, is project-controlled and never a trust root. */
|
|
57
|
+
function homeInsideRoot(home, root) {
|
|
58
|
+
const h = home === '' ? undefined : realRoot(home);
|
|
59
|
+
if (h === undefined)
|
|
60
|
+
return undefined;
|
|
61
|
+
const inside = same(h, root) || (h.length > root.length && same(h.slice(0, root.length), root) && h[root.length] === sep);
|
|
62
|
+
return inside ? `the home directory ${h} is inside the project root` : undefined;
|
|
63
|
+
}
|
|
64
|
+
function isGrant(value) {
|
|
65
|
+
if (typeof value !== 'object' || value === null)
|
|
66
|
+
return false;
|
|
67
|
+
const g = value;
|
|
68
|
+
return (typeof g.root === 'string' &&
|
|
69
|
+
typeof g.expires === 'string' &&
|
|
70
|
+
Number.isFinite(Date.parse(g.expires)) &&
|
|
71
|
+
(g.note === undefined || typeof g.note === 'string'));
|
|
72
|
+
}
|
|
73
|
+
/** Reads the grants. A missing file is an empty list; an unsafe or malformed one is an error. */
|
|
74
|
+
export function readGrants(home = userHome()) {
|
|
75
|
+
const file = delegationFile(home);
|
|
76
|
+
const place = grantsDirProblem(home);
|
|
77
|
+
if (place !== undefined)
|
|
78
|
+
return { ok: false, reason: place };
|
|
79
|
+
if (!existsSync(file))
|
|
80
|
+
return { ok: true, grants: [] };
|
|
81
|
+
try {
|
|
82
|
+
const info = statSync(file);
|
|
83
|
+
if (process.platform !== 'win32') {
|
|
84
|
+
if ((info.mode & 0o022) !== 0)
|
|
85
|
+
return { ok: false, reason: `${file} is writable by its group or others; run chmod 600 on it` };
|
|
86
|
+
if (typeof process.getuid === 'function' && info.uid !== process.getuid())
|
|
87
|
+
return { ok: false, reason: `${file} is owned by another user` };
|
|
88
|
+
}
|
|
89
|
+
const data = JSON.parse(readFileSync(file, 'utf8'));
|
|
90
|
+
if (!Array.isArray(data.grants) || !data.grants.every(isGrant))
|
|
91
|
+
return { ok: false, reason: `${file} is malformed` };
|
|
92
|
+
return { ok: true, grants: data.grants };
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return { ok: false, reason: `${file} cannot be read as delegation JSON` };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function writeGrants(grants, home) {
|
|
99
|
+
const file = delegationFile(home);
|
|
100
|
+
const unknown = grantsDirProblem(home);
|
|
101
|
+
if (unknown !== undefined)
|
|
102
|
+
return unknown;
|
|
103
|
+
mkdirSync(join(home, '.tslaws'), { recursive: true, mode: 0o700 });
|
|
104
|
+
const place = grantsDirProblem(home);
|
|
105
|
+
if (place !== undefined)
|
|
106
|
+
return place;
|
|
107
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
108
|
+
writeFileSync(temp, `${JSON.stringify({ grants }, null, 2)}\n`, { mode: 0o600 });
|
|
109
|
+
renameSync(temp, file);
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
/** The live grant for a project root. Real paths are compared, and a grant naming a symlink never matches. */
|
|
113
|
+
export function findGrant(root, now, home = userHome()) {
|
|
114
|
+
const real = realRoot(root);
|
|
115
|
+
if (real === undefined)
|
|
116
|
+
return { ok: false, reason: 'the project root does not exist' };
|
|
117
|
+
const inside = homeInsideRoot(home, real);
|
|
118
|
+
if (inside !== undefined)
|
|
119
|
+
return { ok: false, reason: inside };
|
|
120
|
+
const read = readGrants(home);
|
|
121
|
+
if (!read.ok)
|
|
122
|
+
return { ok: false, reason: read.reason };
|
|
123
|
+
const named = read.grants.filter((g) => same(g.root, real) && realRoot(g.root) !== undefined);
|
|
124
|
+
if (named.length === 0)
|
|
125
|
+
return { ok: false, reason: 'no delegation covers this project' };
|
|
126
|
+
const live = named
|
|
127
|
+
.filter((g) => Date.parse(g.expires) > now.getTime())
|
|
128
|
+
.sort((a, b) => Date.parse(b.expires) - Date.parse(a.expires))[0];
|
|
129
|
+
return live === undefined ? { ok: false, reason: 'the delegation has expired' } : { ok: true, grant: live };
|
|
130
|
+
}
|
|
131
|
+
/** Decides whether `accept` may run: a person at a terminal, else a live grant. CI never. */
|
|
132
|
+
export function acceptGate(input) {
|
|
133
|
+
const { env } = input;
|
|
134
|
+
if (env.CI !== undefined)
|
|
135
|
+
return { ok: false, message: 'accept records laws as correct and never runs under CI, even with a delegation' };
|
|
136
|
+
if (input.isTTY && env.TSLAWS_AGENT === undefined)
|
|
137
|
+
return { ok: true, delegated: false };
|
|
138
|
+
const verdict = findGrant(input.root, input.now ?? new Date(), input.home);
|
|
139
|
+
if (verdict.ok)
|
|
140
|
+
return { ok: true, delegated: true, grant: verdict.grant };
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
message: `accept records laws as correct and needs a person at a terminal or a live delegation (${verdict.reason}); ${regrant}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** The gate for acts only a person may do: a terminal, no agent marker, no CI. */
|
|
147
|
+
export function personOnly(input) {
|
|
148
|
+
return input.isTTY && input.env.TSLAWS_AGENT === undefined && input.env.CI === undefined;
|
|
149
|
+
}
|
|
150
|
+
/** Records a grant for a project; returns it, or the reason it was refused. */
|
|
151
|
+
export function grantFor(root, ms, now, home = userHome()) {
|
|
152
|
+
const real = realRoot(root);
|
|
153
|
+
if (real === undefined)
|
|
154
|
+
return `${root} does not exist`;
|
|
155
|
+
if (!same(resolve(root), real))
|
|
156
|
+
return `${root} is a symlink to ${real}; name the real directory`;
|
|
157
|
+
const inside = homeInsideRoot(home, real);
|
|
158
|
+
if (inside !== undefined)
|
|
159
|
+
return inside;
|
|
160
|
+
const read = readGrants(home);
|
|
161
|
+
if (!read.ok)
|
|
162
|
+
return read.reason;
|
|
163
|
+
const grant = { root: real, expires: new Date(now.getTime() + ms).toISOString() };
|
|
164
|
+
const problem = writeGrants([...read.grants.filter((g) => !same(g.root, real)), grant], home);
|
|
165
|
+
return problem ?? grant;
|
|
166
|
+
}
|
|
167
|
+
/** Removes the grants for a project; returns how many, or the reason it was refused. */
|
|
168
|
+
export function revokeFor(root, home = userHome()) {
|
|
169
|
+
const real = realRoot(root);
|
|
170
|
+
if (real === undefined)
|
|
171
|
+
return `${root} does not exist`;
|
|
172
|
+
const read = readGrants(home);
|
|
173
|
+
if (!read.ok)
|
|
174
|
+
return read.reason;
|
|
175
|
+
const kept = read.grants.filter((g) => !same(g.root, real));
|
|
176
|
+
if (kept.length !== read.grants.length) {
|
|
177
|
+
const problem = writeGrants(kept, home);
|
|
178
|
+
if (problem !== undefined)
|
|
179
|
+
return problem;
|
|
180
|
+
}
|
|
181
|
+
return read.grants.length - kept.length;
|
|
182
|
+
}
|
|
183
|
+
export function describeGrants(now, home = userHome()) {
|
|
184
|
+
const read = readGrants(home);
|
|
185
|
+
if (!read.ok)
|
|
186
|
+
return [`delegation unusable: ${read.reason}`];
|
|
187
|
+
if (read.grants.length === 0)
|
|
188
|
+
return ['no delegations'];
|
|
189
|
+
return read.grants.map((g) => `${Date.parse(g.expires) > now.getTime() ? 'live' : 'expired'} ${g.root} until ${g.expires}${g.note ? ` (${g.note})` : ''}`);
|
|
190
|
+
}
|
|
191
|
+
export function fileHash(path) {
|
|
192
|
+
return existsSync(path) ? createHash('sha256').update(readFileSync(path)).digest('hex') : 'none';
|
|
193
|
+
}
|
|
194
|
+
/** Appends the audit line for a delegated accept to `<project>/.tslaws/accept.log`. */
|
|
195
|
+
export function logDelegatedAccept(root, grant, before, after, now) {
|
|
196
|
+
const dir = join(root, '.tslaws');
|
|
197
|
+
mkdirSync(dir, { recursive: true });
|
|
198
|
+
appendFileSync(join(dir, 'accept.log'), `${now.toISOString()} delegated expires=${grant.expires} lock-before=${before} lock-after=${after}\n`);
|
|
199
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tslaws",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Runs and checks `law` declarations compiled by tsc-laws: property tests, SMT proofs, tamper lock.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tslaws": "bin/tslaws.js"
|
|
@@ -58,7 +58,9 @@
|
|
|
58
58
|
"z3-solver": "^4.15.0"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@
|
|
61
|
+
"@tsrx/ripple": "0.2.11",
|
|
62
|
+
"@types/node": "^24.0.0",
|
|
63
|
+
"ripple": "0.6.1"
|
|
62
64
|
},
|
|
63
65
|
"peerDependencies": {
|
|
64
66
|
"axe-core": "^4.0.0"
|
|
@@ -69,11 +71,11 @@
|
|
|
69
71
|
}
|
|
70
72
|
},
|
|
71
73
|
"optionalDependencies": {
|
|
72
|
-
"tslaws-compiler-darwin-arm64": "0.
|
|
73
|
-
"tslaws-compiler-darwin-x64": "0.
|
|
74
|
-
"tslaws-compiler-linux-arm64-gnu": "0.
|
|
75
|
-
"tslaws-compiler-linux-x64-gnu": "0.
|
|
76
|
-
"tslaws-compiler-win32-x64-msvc": "0.
|
|
74
|
+
"tslaws-compiler-darwin-arm64": "0.5.0",
|
|
75
|
+
"tslaws-compiler-darwin-x64": "0.5.0",
|
|
76
|
+
"tslaws-compiler-linux-arm64-gnu": "0.5.0",
|
|
77
|
+
"tslaws-compiler-linux-x64-gnu": "0.5.0",
|
|
78
|
+
"tslaws-compiler-win32-x64-msvc": "0.5.0"
|
|
77
79
|
},
|
|
78
80
|
"publishConfig": {
|
|
79
81
|
"access": "public"
|