@bramas/simulant-cli 0.0.0-stage → 0.1.1
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/LICENCE.txt +515 -0
- package/README.md +27 -2
- package/bin/simulant.mjs +12 -0
- package/dist/cli.js +210 -0
- package/dist/explorer-commands.js +112 -0
- package/dist/lean.js +118 -0
- package/dist/project-registry.js +108 -0
- package/dist/smoke-checker.js +21 -0
- package/dist/watch.js +51 -0
- package/package.json +34 -3
- package/src/cli.ts +211 -0
- package/src/explorer-commands.ts +122 -0
- package/src/lean.ts +119 -0
- package/src/project-registry.ts +107 -0
- package/src/smoke-checker.ts +21 -0
- package/src/watch.ts +62 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
function depthArgument(value) {
|
|
4
|
+
const number = Number(value);
|
|
5
|
+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(number)) {
|
|
6
|
+
throw new InvalidArgumentError('Expected a non-negative integer.');
|
|
7
|
+
}
|
|
8
|
+
return number;
|
|
9
|
+
}
|
|
10
|
+
function countArgument(value) {
|
|
11
|
+
const number = depthArgument(value);
|
|
12
|
+
if (number === 0)
|
|
13
|
+
throw new InvalidArgumentError('Expected a positive integer.');
|
|
14
|
+
return number;
|
|
15
|
+
}
|
|
16
|
+
import { resolve as resolvePath } from "node:path";
|
|
17
|
+
import { listProjects as listAvailableProjects, loadProject as loadNamedProject, locateProject } from "./project-registry.js";
|
|
18
|
+
import { leanCommand } from "./lean.js";
|
|
19
|
+
import { runSmokeChecker } from "./smoke-checker.js";
|
|
20
|
+
import * as explorer from "./explorer-commands.js";
|
|
21
|
+
import { requestWatch, watchProject } from "./watch.js";
|
|
22
|
+
const print = (line) => console.log(line);
|
|
23
|
+
/** Explorer limits from the options: no depth limit unless given (liveness needs the whole graph). */
|
|
24
|
+
const explorerOptions = (options) => ({
|
|
25
|
+
maxDepth: options.maxDepth, maxStates: options.maxStates, maxConfigs: options.maxConfigs, perSetting: options.perSetting,
|
|
26
|
+
});
|
|
27
|
+
const program = new Command();
|
|
28
|
+
const metadata = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
29
|
+
program
|
|
30
|
+
.name('simulant')
|
|
31
|
+
.description('CLI tool for testing and generating artifacts from mobile entities projects')
|
|
32
|
+
.version(metadata.version)
|
|
33
|
+
.option('--root <directory>', 'Project or workspace directory (default: current directory)')
|
|
34
|
+
.option('--registry <file>', 'Project registry module, relative to --root or the current directory');
|
|
35
|
+
const projectSource = () => program.opts();
|
|
36
|
+
const loadProject = (name, watch = false) => {
|
|
37
|
+
if (watch)
|
|
38
|
+
requestWatch();
|
|
39
|
+
return loadNamedProject(name, projectSource());
|
|
40
|
+
};
|
|
41
|
+
/** A simulation that provides the explorer's commands (a `.sim` project, see `simProject`). */
|
|
42
|
+
async function loadExplorable(name, watch, needs) {
|
|
43
|
+
const project = await loadProject(name, watch);
|
|
44
|
+
if (!project[needs]) {
|
|
45
|
+
console.error(`Error: Project "${name}" does not provide ${needs} (simulations are .sim experiments registered with simProject)`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
return project;
|
|
49
|
+
}
|
|
50
|
+
function parseSettings(text) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(text);
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
console.error(`Error: Invalid JSON in settings: ${e}`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Runs once and exits with the run's code, or, with --watch, again on each new revision. */
|
|
60
|
+
async function runOrWatch(project, watch, run) {
|
|
61
|
+
if (!watch)
|
|
62
|
+
process.exit(await run());
|
|
63
|
+
await watchProject(project, run, print).done;
|
|
64
|
+
}
|
|
65
|
+
const watchOption = '-w, --watch';
|
|
66
|
+
const watchHelp = 'Run again on every change of the .sim sources';
|
|
67
|
+
const listProjects = () => listAvailableProjects(projectSource());
|
|
68
|
+
// Command: generate-config-graph
|
|
69
|
+
program
|
|
70
|
+
.command('generate-config-graph')
|
|
71
|
+
.description('Generate a configuration graph in TikZ format')
|
|
72
|
+
.requiredOption('-p, --project <name>', 'Project name (e.g., gathering-on-rings)')
|
|
73
|
+
.requiredOption('-s, --settings <json>', 'Settings as JSON string')
|
|
74
|
+
.option('-d, --max-depth <number>', 'Maximum depth to explore (default: unlimited)', depthArgument)
|
|
75
|
+
.option('--max-states <number>', 'Maximum number of states', countArgument)
|
|
76
|
+
.option('-o, --output <file>', 'Output file (default: stdout)')
|
|
77
|
+
.option(watchOption, watchHelp)
|
|
78
|
+
.action(async (options) => {
|
|
79
|
+
const projectName = options.project;
|
|
80
|
+
const project = await loadExplorable(projectName, options.watch, 'stateGraph');
|
|
81
|
+
const settings = parseSettings(options.settings);
|
|
82
|
+
await runOrWatch(project, options.watch, async () => {
|
|
83
|
+
try {
|
|
84
|
+
const { tikz, states, complete } = explorer.stateGraphTikz(project, settings, explorerOptions(options));
|
|
85
|
+
console.log(`State graph of ${projectName} with ${states} states${complete ? '' : ' (incomplete: limit reached)'}`);
|
|
86
|
+
if (options.output) {
|
|
87
|
+
const fs = await import("fs/promises");
|
|
88
|
+
await fs.writeFile(options.output, tikz, 'utf-8');
|
|
89
|
+
console.log(`TikZ output written to ${options.output}`);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
console.log('\n=== TikZ Output ===\n');
|
|
93
|
+
console.log(tikz);
|
|
94
|
+
}
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
console.error(`Error generating graph: ${e instanceof Error ? e.stack : e}`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
// Command: check-config
|
|
104
|
+
program
|
|
105
|
+
.command('check-config')
|
|
106
|
+
.description('Check all possible execution paths from a configuration')
|
|
107
|
+
.requiredOption('-p, --project <name>', 'Project name')
|
|
108
|
+
.requiredOption('-s, --settings <json>', 'Settings as JSON string')
|
|
109
|
+
.option('-d, --max-depth <number>', 'Maximum depth to explore (default: unlimited)', depthArgument)
|
|
110
|
+
.option('--max-states <number>', 'Maximum number of states', countArgument)
|
|
111
|
+
.option(watchOption, watchHelp)
|
|
112
|
+
.action(async (options) => {
|
|
113
|
+
const projectName = options.project;
|
|
114
|
+
const project = await loadExplorable(projectName, options.watch, 'check');
|
|
115
|
+
const settings = parseSettings(options.settings);
|
|
116
|
+
await runOrWatch(project, options.watch, async (signal) => {
|
|
117
|
+
try {
|
|
118
|
+
return await explorer.checkConfig(project, projectName, settings, { ...explorerOptions(options), signal }, print);
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
if (signal?.aborted)
|
|
122
|
+
throw e;
|
|
123
|
+
console.error(`Error checking config: ${e instanceof Error ? e.stack : e}`);
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
// Command: check-all
|
|
129
|
+
program
|
|
130
|
+
.command('check-all')
|
|
131
|
+
.description('Check every setting of the experiment\'s parameter domains')
|
|
132
|
+
.requiredOption('-p, --project <name>', 'Project name')
|
|
133
|
+
.option('-d, --max-depth <number>', 'Maximum depth to explore per configuration (default: unlimited)', depthArgument)
|
|
134
|
+
.option('--max-states <number>', 'Maximum number of states per state graph', countArgument)
|
|
135
|
+
.option('--max-configs <number>', 'Maximum number of configurations to test (default: all)', countArgument)
|
|
136
|
+
.option('--per-setting', 'One state graph per setting, instead of one per ring size')
|
|
137
|
+
.option(watchOption, watchHelp)
|
|
138
|
+
.action(async (options) => {
|
|
139
|
+
const projectName = options.project;
|
|
140
|
+
const project = await loadExplorable(projectName, options.watch, 'checkAll');
|
|
141
|
+
const progress = process.stdout.isTTY ? (line) => process.stdout.write(line) : undefined;
|
|
142
|
+
await runOrWatch(project, options.watch, async (signal) => {
|
|
143
|
+
try {
|
|
144
|
+
return await explorer.checkAll(project, projectName, { ...explorerOptions(options), signal }, print, progress);
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
if (signal?.aborted)
|
|
148
|
+
throw e;
|
|
149
|
+
console.error(`\n\nError during testing: ${e instanceof Error ? e.stack : e}`);
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
program
|
|
155
|
+
.command('lean')
|
|
156
|
+
.description('Generate into <project>/lean/Generated; preserves Proofs/ and existing Lake configuration')
|
|
157
|
+
.option('-p, --project <name>', 'Simulation name (inferred when exactly one is available)')
|
|
158
|
+
.option('-o, --out <directory>', 'Lean directory (default: <project>/lean)')
|
|
159
|
+
.option('-b, --build', 'Run lake build afterwards (checks the model and the proofs)')
|
|
160
|
+
.option(watchOption, 'Generate again (and build, with --build) on every change of the .sim sources')
|
|
161
|
+
.action(async (options) => {
|
|
162
|
+
if (options.watch)
|
|
163
|
+
requestWatch();
|
|
164
|
+
let name = options.project;
|
|
165
|
+
if (!name) {
|
|
166
|
+
const names = await listProjects();
|
|
167
|
+
if (!names.length)
|
|
168
|
+
throw new Error('No simulations found. Run from a project directory, or select one with --root or --registry.');
|
|
169
|
+
if (names.length > 1)
|
|
170
|
+
throw new Error(`Several simulations are available: ${names.join(', ')}. Choose one with --project <name> (or -p <name>).`);
|
|
171
|
+
name = names[0];
|
|
172
|
+
}
|
|
173
|
+
const { project, directory } = await locateProject(name, projectSource());
|
|
174
|
+
await runOrWatch(project, options.watch, async (signal) => {
|
|
175
|
+
try {
|
|
176
|
+
return await leanCommand(project, name, directory, { out: options.out && resolvePath(options.out), build: options.build, signal }, print);
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
if (signal?.aborted)
|
|
180
|
+
throw e;
|
|
181
|
+
console.error(`Error: ${e instanceof Error ? e.message : e}`);
|
|
182
|
+
return 1;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
program
|
|
187
|
+
.command('list-projects')
|
|
188
|
+
.description('List simulations from public packages and available local private projects')
|
|
189
|
+
.action(async () => {
|
|
190
|
+
for (const name of await listProjects())
|
|
191
|
+
console.log(name);
|
|
192
|
+
});
|
|
193
|
+
program
|
|
194
|
+
.command('check [project]')
|
|
195
|
+
.description('Smoke check the first execution branch for each generated setting')
|
|
196
|
+
.option('-p, --project <name>', 'Simulation name')
|
|
197
|
+
.option(watchOption, watchHelp)
|
|
198
|
+
.action(async (name, options) => {
|
|
199
|
+
const projectName = options.project ?? name;
|
|
200
|
+
if (options.watch && projectName) {
|
|
201
|
+
const project = await loadExplorable(projectName, true, 'checkFirstBranch');
|
|
202
|
+
await runOrWatch(project, true, async () => explorer.checkFirstBranches(project, projectName, {}, print));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
await runSmokeChecker(projectName, projectSource());
|
|
206
|
+
});
|
|
207
|
+
program.parseAsync().catch(error => {
|
|
208
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
209
|
+
process.exitCode = 1;
|
|
210
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export const exitCode = (r) => r.status === 'success' ? 0 : r.status === 'failure' ? 1 : 2;
|
|
2
|
+
/** Prints a counterexample by replaying its path in the project's execution, as the simulator does. */
|
|
3
|
+
export function printTrace(project, r, print) {
|
|
4
|
+
const trace = r.trace;
|
|
5
|
+
if (!trace)
|
|
6
|
+
return;
|
|
7
|
+
const model = project.setup(trace.settings);
|
|
8
|
+
const execution = project.execute(model);
|
|
9
|
+
print(`Counterexample (${trace.kind === 'lasso' ? 'an execution that repeats forever' : 'a failing configuration'}):`);
|
|
10
|
+
print(` settings ${JSON.stringify(trace.settings)}, path [${trace.path.join(', ')}] (load both in the simulator to replay it)`);
|
|
11
|
+
trace.path.forEach((_, i) => {
|
|
12
|
+
const prefix = trace.path.slice(0, i + 1);
|
|
13
|
+
const shown = project.debugConfig ? project.debugConfig(execution.get(prefix), model) : '';
|
|
14
|
+
const label = trace.steps[i].label ? ` (${trace.steps[i].label})` : '';
|
|
15
|
+
const cycle = trace.cycleStart === i ? ' <- the cycle starts here' : '';
|
|
16
|
+
print(` Step ${i}${label}: ${shown} [${trace.steps[i].verdict}]${cycle}`);
|
|
17
|
+
});
|
|
18
|
+
if (trace.kind === 'lasso')
|
|
19
|
+
print(` The last step returns to step ${trace.cycleStart} (up to symmetry); the steps after it repeat forever.`);
|
|
20
|
+
}
|
|
21
|
+
function printStats(r, print) {
|
|
22
|
+
const s = r.stats;
|
|
23
|
+
const graphs = r.settings !== undefined ? `, ${r.settings} settings in ${r.graphs} state graph(s)` : '';
|
|
24
|
+
print(`States: ${s.states} (expanded ${s.expanded}, edges ${s.edges}, depth ${s.maxDepth}, symmetry group of ${s.group})${graphs}`);
|
|
25
|
+
if (r.revision)
|
|
26
|
+
print(`Revision: ${r.revision}`);
|
|
27
|
+
}
|
|
28
|
+
function printVerdict(r, print, what) {
|
|
29
|
+
if (r.status === 'success')
|
|
30
|
+
print(`✅ ${what}: ${r.reason}`);
|
|
31
|
+
else if (r.status === 'failure')
|
|
32
|
+
print(`❌ ${what}: ${r.reason}`);
|
|
33
|
+
else
|
|
34
|
+
print(`⚠️ ${what}: exploration incomplete (${r.reason})`);
|
|
35
|
+
}
|
|
36
|
+
export async function checkConfig(project, name, settings, options, print) {
|
|
37
|
+
print(`Checking every admissible execution of ${name} (explorer)...`);
|
|
38
|
+
print(`Settings: ${JSON.stringify(settings)}`);
|
|
39
|
+
const r = await project.check(settings, options);
|
|
40
|
+
print('');
|
|
41
|
+
print('=== Result ===');
|
|
42
|
+
printStats(r, print);
|
|
43
|
+
printVerdict(r, print, 'Result');
|
|
44
|
+
printTrace(project, r, print);
|
|
45
|
+
return exitCode(r);
|
|
46
|
+
}
|
|
47
|
+
export async function checkAll(project, name, options, print, progress) {
|
|
48
|
+
print(`Checking every generated setting of ${name} (explorer${options.perSetting ? ', one state graph per setting' : ', settings of a ring size share a state graph'})...`);
|
|
49
|
+
const r = await project.checkAll(options, (done, total) => progress?.(`\rChecked ${done}/${total} settings`));
|
|
50
|
+
progress?.('\n');
|
|
51
|
+
print('');
|
|
52
|
+
print('=== Summary ===');
|
|
53
|
+
printStats(r, print);
|
|
54
|
+
printVerdict(r, print, r.status === 'failure' ? 'FAILURE FOUND' : r.status === 'success' ? 'ALL CONFIGURATIONS PASSED' : 'INCOMPLETE');
|
|
55
|
+
printTrace(project, r, print);
|
|
56
|
+
return exitCode(r);
|
|
57
|
+
}
|
|
58
|
+
/** `simulant check`: the first branch of every generated setting. */
|
|
59
|
+
export function checkFirstBranches(project, name, options, print) {
|
|
60
|
+
print(`Starting checks for project: ${name} (first branch of each setting, explorer)`);
|
|
61
|
+
let tested = 0, succeeded = 0, failed = 0;
|
|
62
|
+
for (const settings of project.settingsGenerator?.() ?? []) {
|
|
63
|
+
tested++;
|
|
64
|
+
const r = project.checkFirstBranch(settings, options);
|
|
65
|
+
if (r.status === 'success') {
|
|
66
|
+
succeeded++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
failed++;
|
|
70
|
+
print(`Result: ${r.status === 'failure' ? 'Failure' : 'Incomplete'} (${r.reason})`);
|
|
71
|
+
print(`Settings: ${JSON.stringify(settings)}`);
|
|
72
|
+
printTrace(project, r, print);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
print('\n--- Summary ---');
|
|
76
|
+
print(`Project: ${name}`);
|
|
77
|
+
print(`Total Tested: ${tested}`);
|
|
78
|
+
print(`Succeeded: ${succeeded}`);
|
|
79
|
+
print(`Failed: ${failed}`);
|
|
80
|
+
print('---------------');
|
|
81
|
+
return failed ? 1 : 0;
|
|
82
|
+
}
|
|
83
|
+
const tex = (s) => s.replace(/[\\{}&%$#_^~]/g, c => c === '\\' ? '\\textbackslash{}' : c === '^' ? '\\^{}' : c === '~' ? '\\~{}' : `\\${c}`);
|
|
84
|
+
/** The explored state graph in TikZ: states by depth, edges labelled with their witnesses. */
|
|
85
|
+
export function stateGraphTikz(project, settings, options) {
|
|
86
|
+
const g = project.stateGraph(settings, options);
|
|
87
|
+
const model = project.setup(settings);
|
|
88
|
+
const lines = [
|
|
89
|
+
'\\begin{tikzpicture}[',
|
|
90
|
+
' node/.style={circle, draw, minimum size=1cm},',
|
|
91
|
+
' success/.style={node, fill=green!20},',
|
|
92
|
+
' failure/.style={node, fill=red!20},',
|
|
93
|
+
' inProgress/.style={node, fill=orange!20},',
|
|
94
|
+
' edge/.style={->, font=\\tiny}',
|
|
95
|
+
']',
|
|
96
|
+
'',
|
|
97
|
+
];
|
|
98
|
+
const perDepth = new Map();
|
|
99
|
+
for (const s of g.states) {
|
|
100
|
+
const x = (perDepth.get(s.depth) ?? 0) * 3;
|
|
101
|
+
perDepth.set(s.depth, x / 3 + 1);
|
|
102
|
+
const shown = project.debugConfig ? tex(project.debugConfig(s.config, model)) : String(s.id);
|
|
103
|
+
lines.push(` \\node[${s.verdict}] (s${s.id}) at (${x},${-s.depth * 2}) {\\begin{tabular}{c}\\tiny s${s.id} \\\\ \\texttt{\\tiny ${shown}}\\end{tabular}};`);
|
|
104
|
+
}
|
|
105
|
+
lines.push('');
|
|
106
|
+
for (const e of g.edges) {
|
|
107
|
+
const loop = e.from === e.to ? ' [loop right]' : '';
|
|
108
|
+
lines.push(` \\draw[edge] (s${e.from}) to${loop} node[midway, sloped, above] {${tex(e.label)}} (s${e.to});`);
|
|
109
|
+
}
|
|
110
|
+
lines.push('\\end{tikzpicture}');
|
|
111
|
+
return { tikz: lines.join('\n'), states: g.states.length, complete: g.complete };
|
|
112
|
+
}
|
package/dist/lean.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lean generation for a project (plan, Sections 5 and 6.6): `simulant lean -p <simulation>`.
|
|
3
|
+
*
|
|
4
|
+
* <project>/lean/lakefile.toml created once, then the researcher's
|
|
5
|
+
* <project>/lean/lean-toolchain the workspace's toolchain, created once
|
|
6
|
+
* <project>/lean/Generated/<Module>/Model.lean replaced by each generation
|
|
7
|
+
* <project>/lean/Generated/<Module>/revision.json source revision and toolchain of the file
|
|
8
|
+
* <project>/lean/Proofs/ hand-written proofs: never written by generation
|
|
9
|
+
*
|
|
10
|
+
* After a change, the proofs must be checked again (`--build` runs `lake build`, which also
|
|
11
|
+
* checks them, after a scan for constructs a proof file may not use).
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
17
|
+
import { checkProofFile } from "@bramas/simulant-lean-gen/check-proofs";
|
|
18
|
+
import { generateModel } from "@bramas/simulant-lean-gen/model";
|
|
19
|
+
import { leanModuleName } from "@bramas/simulant-lean-gen/names";
|
|
20
|
+
const libraryLean = dirname(createRequire(import.meta.url).resolve('@bramas/simulant-lean/package.json'));
|
|
21
|
+
export { leanModuleName } from "@bramas/simulant-lean-gen/names";
|
|
22
|
+
function writeIfChanged(file, text) {
|
|
23
|
+
if (existsSync(file) && readFileSync(file, 'utf8') === text)
|
|
24
|
+
return false;
|
|
25
|
+
writeFileSync(file, text);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
/** Writes the generated files; returns the paths written. Never touches `Proofs/`. */
|
|
29
|
+
export function generateProjectLean(project, simulation, projectDir, out = join(projectDir, 'lean')) {
|
|
30
|
+
if (!project.experiment)
|
|
31
|
+
throw new Error(`${simulation} is not a .sim project: there is nothing to generate`);
|
|
32
|
+
const module = leanModuleName(simulation);
|
|
33
|
+
const exp = project.experiment().ir;
|
|
34
|
+
const written = [];
|
|
35
|
+
mkdirSync(join(out, 'Generated', module), { recursive: true });
|
|
36
|
+
mkdirSync(join(out, 'Proofs'), { recursive: true });
|
|
37
|
+
const lakefile = join(out, 'lakefile.toml');
|
|
38
|
+
if (!existsSync(lakefile)) {
|
|
39
|
+
writeFileSync(lakefile, `name = "${leanModuleName(projectDir.split('/').pop() ?? 'project')}"
|
|
40
|
+
defaultTargets = ["Generated", "Proofs"]
|
|
41
|
+
|
|
42
|
+
# The Simulant semantic core and component library.
|
|
43
|
+
[[require]]
|
|
44
|
+
name = "simulant"
|
|
45
|
+
path = ${JSON.stringify(relative(realpathSync(out), libraryLean).split(sep).join('/') || '.')}
|
|
46
|
+
|
|
47
|
+
# Generated by \`simulant lean\`: replaced by each generation.
|
|
48
|
+
[[lean_lib]]
|
|
49
|
+
name = "Generated"
|
|
50
|
+
globs = ["Generated.+"]
|
|
51
|
+
|
|
52
|
+
# Hand-written proofs: never written by generation.
|
|
53
|
+
[[lean_lib]]
|
|
54
|
+
name = "Proofs"
|
|
55
|
+
globs = ["Proofs.+"]
|
|
56
|
+
`);
|
|
57
|
+
written.push(lakefile);
|
|
58
|
+
}
|
|
59
|
+
const toolchain = join(out, 'lean-toolchain');
|
|
60
|
+
if (!existsSync(toolchain)) {
|
|
61
|
+
writeFileSync(toolchain, readFileSync(join(libraryLean, 'lean-toolchain'), 'utf8'));
|
|
62
|
+
written.push(toolchain);
|
|
63
|
+
}
|
|
64
|
+
const model = join(out, 'Generated', module, 'Model.lean');
|
|
65
|
+
if (writeIfChanged(model, generateModel(exp, `Generated.${module}`)))
|
|
66
|
+
written.push(model);
|
|
67
|
+
const revision = join(out, 'Generated', module, 'revision.json');
|
|
68
|
+
const info = { simulation, experiment: exp.name, revision: project.revision?.(), toolchain: readFileSync(join(libraryLean, 'lean-toolchain'), 'utf8').trim() };
|
|
69
|
+
if (writeIfChanged(revision, JSON.stringify(info, null, 2) + '\n'))
|
|
70
|
+
written.push(revision);
|
|
71
|
+
return { module, out, written };
|
|
72
|
+
}
|
|
73
|
+
/** Problems of the hand-written proof files (forbidden constructs, see check-proofs.ts). */
|
|
74
|
+
export function scanProofs(out) {
|
|
75
|
+
const dir = join(out, 'Proofs');
|
|
76
|
+
const files = existsSync(dir) ? readdirSync(dir, { recursive: true }).map(String).filter(f => f.endsWith('.lean')) : [];
|
|
77
|
+
return files.flatMap(f => checkProofFile(readFileSync(join(dir, f), 'utf8')).map(p => `Proofs/${f}: ${p}`));
|
|
78
|
+
}
|
|
79
|
+
/** `lake build` in the project's Lean directory; cancelled by `signal`. */
|
|
80
|
+
export function lakeBuild(out, print, signal) {
|
|
81
|
+
return new Promise(resolve => {
|
|
82
|
+
const child = spawn('lake', ['build'], { cwd: out, signal });
|
|
83
|
+
let output = '';
|
|
84
|
+
child.stdout.on('data', d => { output += d; });
|
|
85
|
+
child.stderr.on('data', d => { output += d; });
|
|
86
|
+
child.on('error', error => {
|
|
87
|
+
if (!signal?.aborted)
|
|
88
|
+
print(`lake build failed to start: ${error.message}`);
|
|
89
|
+
resolve(1);
|
|
90
|
+
});
|
|
91
|
+
child.on('close', code => {
|
|
92
|
+
if (signal?.aborted)
|
|
93
|
+
return resolve(1);
|
|
94
|
+
if (code)
|
|
95
|
+
print(output.trim());
|
|
96
|
+
resolve(code ? 1 : 0);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
export async function leanCommand(project, simulation, projectDir, options, print) {
|
|
101
|
+
const { module, out, written } = generateProjectLean(project, simulation, projectDir, options.out);
|
|
102
|
+
print(`Lean model of ${simulation}: ${join(out, 'Generated', module, 'Model.lean')} (revision ${project.revision?.() ?? '?'})`);
|
|
103
|
+
print(written.length ? `Written: ${written.map(f => relative(out, f)).join(', ')}` : 'Up to date: nothing written.');
|
|
104
|
+
if (written.some(f => f.endsWith('Model.lean')))
|
|
105
|
+
print('The proofs in Proofs/ must be checked again against the new definitions.');
|
|
106
|
+
const problems = scanProofs(out);
|
|
107
|
+
for (const p of problems)
|
|
108
|
+
print(`❌ ${p}`);
|
|
109
|
+
if (problems.length)
|
|
110
|
+
return 1;
|
|
111
|
+
if (!options.build)
|
|
112
|
+
return 0;
|
|
113
|
+
print('Running lake build (the generated model and the proofs)...');
|
|
114
|
+
const code = await lakeBuild(out, print, options.signal);
|
|
115
|
+
if (!options.signal?.aborted)
|
|
116
|
+
print(code ? '❌ lake build failed' : '✅ lake build succeeded: the model and the proofs check');
|
|
117
|
+
return code;
|
|
118
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { access, readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
function exportTarget(value) {
|
|
5
|
+
if (typeof value === 'string')
|
|
6
|
+
return value;
|
|
7
|
+
if (value && typeof value === 'object') {
|
|
8
|
+
const conditions = value;
|
|
9
|
+
return exportTarget(conditions.node) ?? exportTarget(conditions.import) ?? exportTarget(conditions.default);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
async function packageAt(directory) {
|
|
13
|
+
let manifest;
|
|
14
|
+
try {
|
|
15
|
+
manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8'));
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error.code === 'ENOENT')
|
|
19
|
+
return;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
const declared = exportTarget(manifest.exports?.['./projects']);
|
|
23
|
+
const candidates = declared ? [declared] : ['projects.ts', 'src/projects.ts'];
|
|
24
|
+
for (const candidate of candidates) {
|
|
25
|
+
const registry = resolve(directory, candidate);
|
|
26
|
+
try {
|
|
27
|
+
await access(registry);
|
|
28
|
+
return { slug: basename(directory), registry };
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code !== 'ENOENT')
|
|
32
|
+
throw error;
|
|
33
|
+
if (declared)
|
|
34
|
+
throw new Error(`Project registry declared in ${directory}/package.json does not exist: ${registry}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function packagesIn(directory) {
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code === 'ENOENT')
|
|
45
|
+
return [];
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
const packages = [];
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
51
|
+
continue;
|
|
52
|
+
const pkg = await packageAt(join(directory, entry.name));
|
|
53
|
+
if (pkg)
|
|
54
|
+
packages.push(pkg);
|
|
55
|
+
}
|
|
56
|
+
return packages.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
57
|
+
}
|
|
58
|
+
export async function discoverProjectPackages(source = {}) {
|
|
59
|
+
const root = resolve(source.root ?? process.cwd());
|
|
60
|
+
if (source.registry)
|
|
61
|
+
return [{ slug: basename(root), registry: resolve(root, source.registry) }];
|
|
62
|
+
const [currentPackage, publicPackages, privatePackages] = await Promise.all([
|
|
63
|
+
packageAt(root), packagesIn(join(root, 'projects')), packagesIn(join(root, 'projects/private')),
|
|
64
|
+
]);
|
|
65
|
+
return [...(currentPackage ? [currentPackage] : []), ...publicPackages, ...privatePackages];
|
|
66
|
+
}
|
|
67
|
+
async function loadRegistry(pkg) {
|
|
68
|
+
const module = await import(pathToFileURL(pkg.registry).href);
|
|
69
|
+
if (!module.projects || typeof module.projects !== 'object') {
|
|
70
|
+
throw new Error(`${pkg.registry} must export a projects map`);
|
|
71
|
+
}
|
|
72
|
+
return module.projects;
|
|
73
|
+
}
|
|
74
|
+
export async function listProjects(source = {}) {
|
|
75
|
+
const registries = await Promise.all((await discoverProjectPackages(source)).map(loadRegistry));
|
|
76
|
+
const names = registries.flatMap(registry => Object.keys(registry));
|
|
77
|
+
if (new Set(names).size !== names.length)
|
|
78
|
+
throw new Error('Duplicate simulation names in project registries');
|
|
79
|
+
return names.sort();
|
|
80
|
+
}
|
|
81
|
+
/** Prefer the matching package so an unrelated private project cannot break a public command. */
|
|
82
|
+
export async function loadProject(name, source = {}) {
|
|
83
|
+
return (await locateProject(name, source)).project;
|
|
84
|
+
}
|
|
85
|
+
/** A simulation and the directory of the package that registers it (its `package.json`). */
|
|
86
|
+
export async function locateProject(name, source = {}) {
|
|
87
|
+
const packages = await discoverProjectPackages(source);
|
|
88
|
+
const matching = packages.filter(pkg => name === pkg.slug || name.startsWith(`${pkg.slug}-`));
|
|
89
|
+
const candidates = matching.length ? matching : packages;
|
|
90
|
+
for (const pkg of candidates) {
|
|
91
|
+
const registry = await loadRegistry(pkg);
|
|
92
|
+
if (Object.hasOwn(registry, name))
|
|
93
|
+
return { project: registry[name], directory: await packageDirectory(pkg.registry) };
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`Unknown project "${name}". Run simulant list-projects to see available simulations.`);
|
|
96
|
+
}
|
|
97
|
+
async function packageDirectory(registry) {
|
|
98
|
+
for (let dir = dirname(registry);; dir = dirname(dir)) {
|
|
99
|
+
try {
|
|
100
|
+
await access(join(dir, 'package.json'));
|
|
101
|
+
return dir;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
if (dirname(dir) === dir)
|
|
105
|
+
return dirname(registry);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { listProjects, loadProject } from "./project-registry.js";
|
|
2
|
+
import { checkFirstBranches } from "./explorer-commands.js";
|
|
3
|
+
// This checks the first branch at each step. Use cli check-config for exhaustive checking.
|
|
4
|
+
export async function runSmokeChecker(projectName, source = {}) {
|
|
5
|
+
if (!projectName) {
|
|
6
|
+
console.log('Usage: simulant check <simulation-name>');
|
|
7
|
+
console.log('Available projects:');
|
|
8
|
+
for (const name of await listProjects(source))
|
|
9
|
+
console.log(` - ${name}`);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const project = await loadProject(projectName, source);
|
|
13
|
+
if (!project.checkFirstBranch) {
|
|
14
|
+
console.error(`Error: Project "${projectName}" does not provide checkFirstBranch (simulations are .sim experiments registered with simProject)`);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
// The first branch, judged by the explorer's goal semantics.
|
|
18
|
+
const code = checkFirstBranches(project, projectName, {}, line => console.log(line));
|
|
19
|
+
if (code)
|
|
20
|
+
process.exit(code);
|
|
21
|
+
}
|
package/dist/watch.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Must be called before the registry is loaded: compiled `.sim` modules then watch their sources. */
|
|
2
|
+
export function requestWatch() {
|
|
3
|
+
globalThis.__simulantWatch = true;
|
|
4
|
+
}
|
|
5
|
+
export class Superseded extends Error {
|
|
6
|
+
constructor() { super('superseded by a newer revision'); }
|
|
7
|
+
}
|
|
8
|
+
export function watchProject(project, run, print) {
|
|
9
|
+
if (!project.subscribe)
|
|
10
|
+
throw new Error('--watch needs a .sim project');
|
|
11
|
+
let controller;
|
|
12
|
+
const start = () => {
|
|
13
|
+
controller?.abort(new Superseded());
|
|
14
|
+
const own = controller = new AbortController();
|
|
15
|
+
print(`--- revision ${project.revision?.() ?? '?'} (${new Date().toLocaleTimeString()}) ---`);
|
|
16
|
+
run(own.signal).then(code => { if (!own.signal.aborted)
|
|
17
|
+
print(`--- exit code ${code}; watching for changes (Ctrl-C to stop) ---`); }, error => {
|
|
18
|
+
if (own.signal.aborted)
|
|
19
|
+
return;
|
|
20
|
+
print(`Error: ${error instanceof Error ? error.message : error}`);
|
|
21
|
+
print('--- exit code 1; watching for changes (Ctrl-C to stop) ---');
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
const unsubscribe = project.subscribe(update => {
|
|
25
|
+
if (update.kind === 'replaced') {
|
|
26
|
+
print('');
|
|
27
|
+
print('Sources changed.');
|
|
28
|
+
start();
|
|
29
|
+
}
|
|
30
|
+
else if (update.kind === 'failed') {
|
|
31
|
+
controller?.abort(new Superseded());
|
|
32
|
+
print('');
|
|
33
|
+
print(`--- revision ${update.revision}: the sources do not compile ---`);
|
|
34
|
+
for (const d of update.diagnostics)
|
|
35
|
+
print(d);
|
|
36
|
+
print('--- watching for changes ---');
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
print(`(sources changed without changing the experiment: revision ${project.revision?.()}, results unchanged)`);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
// The watchers poll the sources; this keeps the process alive until stopped.
|
|
43
|
+
let resolve;
|
|
44
|
+
const done = new Promise(r => { resolve = r; });
|
|
45
|
+
const alive = setInterval(() => { }, 1 << 30);
|
|
46
|
+
start();
|
|
47
|
+
return {
|
|
48
|
+
done,
|
|
49
|
+
stop: () => { controller?.abort(new Superseded()); unsubscribe(); clearInterval(alive); resolve(); },
|
|
50
|
+
};
|
|
51
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bramas/simulant-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "CECILL-B",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20.6.0"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"simulant": "./bin/simulant.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"dist",
|
|
16
|
+
"bin",
|
|
17
|
+
"LICENCE.txt"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"registry": "https://registry.npmjs.org",
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"commander": "^14.0.2",
|
|
25
|
+
"tsx": "^4.20.6",
|
|
26
|
+
"@bramas/simulant-core": "0.1.1",
|
|
27
|
+
"@bramas/simulant-lean-gen": "0.1.1",
|
|
28
|
+
"@bramas/simulant-lang": "0.1.1",
|
|
29
|
+
"@bramas/simulant-lean": "0.1.1",
|
|
30
|
+
"@bramas/simulant-vite-plugin": "0.1.1"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/Bramas/simulant.git",
|
|
35
|
+
"directory": "packages/cli"
|
|
36
|
+
}
|
|
6
37
|
}
|