@rsconcept/rstool 1.7.1 → 2.0.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 +7 -7
- package/dist/index.js +1 -1
- package/dist/mappers/model-adapter.d.ts +6 -2
- package/dist/mappers/model-adapter.js +28 -3
- package/dist/mappers/model-adapter.js.map +1 -1
- package/dist/mappers/schema-adapter.d.ts +1 -2
- package/dist/mappers/schema-adapter.js +1 -1
- package/dist/mappers/types.d.ts +4 -5
- package/dist/models/index.js +1 -1
- package/dist/models/rstool-agent.js +1 -2
- package/dist/{models-DG2uTaV_.js → models-D06g8LId.js} +6 -5
- package/dist/{models-DG2uTaV_.js.map → models-D06g8LId.js.map} +1 -1
- package/dist/{rsform-builder-DKwZfJvN.js → rsform-builder-BlXEe5Ow.js} +2 -2
- package/dist/{rsform-builder-DKwZfJvN.js.map → rsform-builder-BlXEe5Ow.js.map} +1 -1
- package/dist/session/session-store.d.ts +3 -3
- package/dist/session/session-store.js +1 -2
- package/dist/wrapper/client.d.ts +15 -9
- package/dist/wrapper/client.js +79 -12
- package/dist/wrapper/client.js.map +1 -1
- package/dist/wrapper/stdio-wrapper.d.ts +1 -1
- package/dist/wrapper/stdio-wrapper.js +18 -10
- package/dist/wrapper/stdio-wrapper.js.map +1 -1
- package/docs/BRIEF.md +93 -0
- package/docs/CONCEPTUAL-SCHEMA.md +17 -2
- package/docs/CONSTRUCTIONS.md +384 -0
- package/docs/DIAGNOSTICS.md +9 -8
- package/docs/DOMAIN.md +12 -0
- package/docs/METHOD.md +208 -0
- package/docs/MODEL-TESTING.md +48 -0
- package/docs/PORTAL-API.md +38 -285
- package/docs/PORTAL-MODELS.md +24 -0
- package/docs/PORTAL-OSS.md +56 -0
- package/docs/PORTAL-PUBLISH.md +39 -0
- package/docs/PORTAL-READ.md +301 -0
- package/docs/README.md +8 -4
- package/docs/REQUIREMENTS.md +61 -0
- package/docs/SYNTAX.md +5 -4
- package/docs/TYPIFICATION.md +12 -3
- package/docs/brief.schema.json +173 -0
- package/examples/README.md +16 -2
- package/examples/access-control/NOTES.md +23 -0
- package/examples/access-control/anti/collapsed.json +439 -0
- package/examples/access-control/anti/weakened.json +439 -0
- package/examples/access-control/brief-v1.json +231 -0
- package/examples/access-control/brief.json +310 -0
- package/examples/access-control/build-rsform.ts +151 -0
- package/examples/access-control/requirements-v1.md +9 -0
- package/examples/access-control/requirements-v2.md +4 -0
- package/examples/access-control/rsform-session-v1.json +439 -0
- package/examples/access-control/rsform-session.json +523 -0
- package/examples/audit-diagnostics.ts +9 -1
- package/examples/briefs.test.ts +126 -0
- package/examples/clinic-booking/NOTES.md +24 -0
- package/examples/clinic-booking/anti/collapsed.json +615 -0
- package/examples/clinic-booking/anti/species-as-genus.json +568 -0
- package/examples/clinic-booking/brief-v1.json +332 -0
- package/examples/clinic-booking/brief.json +405 -0
- package/examples/clinic-booking/build-rsform.ts +174 -0
- package/examples/clinic-booking/requirements-v1.md +9 -0
- package/examples/clinic-booking/requirements-v2.md +4 -0
- package/examples/clinic-booking/rsform-session-v1.json +615 -0
- package/examples/clinic-booking/rsform-session.json +700 -0
- package/examples/diagnostics-utils.ts +26 -0
- package/examples/kinship/brief.json +337 -0
- package/examples/kinship/build-rsform.ts +7 -4
- package/examples/kinship/rsform-session.json +20 -63
- package/examples/kinship/rsmodel-session.json +14 -14
- package/examples/kinship-subordination/NOTES.md +49 -0
- package/examples/kinship-subordination/brief.json +161 -0
- package/examples/kinship-subordination/build-rsform.ts +100 -0
- package/examples/kinship-subordination/rsform-session.json +795 -0
- package/examples/library-loans/NOTES.md +46 -0
- package/examples/library-loans/anti/collapsed.json +481 -0
- package/examples/library-loans/anti/species-as-genus.json +334 -0
- package/examples/library-loans/anti/uncovered.json +406 -0
- package/examples/library-loans/anti/weakened.json +464 -0
- package/examples/library-loans/brief-v1.json +340 -0
- package/examples/library-loans/brief.json +502 -0
- package/examples/library-loans/build-rsform.ts +239 -0
- package/examples/library-loans/requirements-v1.md +11 -0
- package/examples/library-loans/requirements-v2.md +5 -0
- package/examples/library-loans/rsform-session-v1.json +481 -0
- package/examples/library-loans/rsform-session.json +652 -0
- package/examples/library-loans/source-markup.md +18 -0
- package/examples/movd/brief.json +220 -0
- package/examples/movd/build-rsform.ts +5 -2
- package/examples/movd/rsform-session.json +14 -46
- package/examples/subordination/NOTES.md +59 -0
- package/examples/subordination/anti/collapsed.json +556 -0
- package/examples/subordination/anti/single-relation.json +125 -0
- package/examples/subordination/anti/transitive.json +573 -0
- package/examples/subordination/anti/weakened.json +539 -0
- package/examples/subordination/brief.json +386 -0
- package/examples/subordination/build-rsform.ts +63 -0
- package/examples/subordination/constants.ts +115 -0
- package/examples/subordination/rsform-session.json +556 -0
- package/examples/subordination/source-markup.md +19 -0
- package/examples/subordination/source.md +40 -0
- package/package.json +29 -20
- package/skills/INSTALL.md +28 -29
- package/skills/README.md +25 -10
- package/skills/rs-core/SKILL.md +56 -0
- package/skills/rs-explain/SKILL.md +28 -0
- package/skills/rs-explicate/SKILL.md +71 -0
- package/skills/rs-interpret/SKILL.md +67 -0
- package/skills/rs-portal/SKILL.md +51 -0
- package/skills/rs-review/SKILL.md +79 -0
- package/skills/rs-scope/SKILL.md +67 -0
- package/skills/rs-synthesis/SKILL.md +52 -0
- package/skills/rstool/SKILL.md +70 -0
- package/skills/rstool-helper/GUIDE.md +18 -272
- package/skills/rstool-helper/SKILL.md +8 -55
- package/skills/{rstool-helper → rstool-runtime}/EXAMPLES.md +40 -12
- package/skills/{rstool-helper → rstool-runtime}/REFERENCE.md +28 -7
- package/skills/rstool-runtime/SKILL.md +96 -0
- package/skills/scripts/brief-check.test.ts +165 -0
- package/skills/scripts/brief-check.ts +826 -0
- package/skills/scripts/check-brief.ts +94 -0
- package/skills/scripts/rstool-skills.mjs +119 -0
- package/skills/scripts/skill-stubs.test.ts +48 -0
- package/src/mappers/model-adapter.ts +34 -1
- package/src/models/rstool-agent.test.ts +95 -0
- package/src/wrapper/client.test.ts +43 -4
- package/src/wrapper/client.ts +95 -16
- package/src/wrapper/stdio-handler.test.ts +43 -0
- package/src/wrapper/stdio-handler.ts +19 -5
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI: check a session against its brief and print readiness.
|
|
3
|
+
*
|
|
4
|
+
* Usage (from the rstool package root):
|
|
5
|
+
* tsx skills/scripts/check-brief.ts --session <session.json> --brief <brief.json> [--base <v1-session.json>] [--review] [--json]
|
|
6
|
+
*
|
|
7
|
+
* Exit code: 0 — no blockers, 1 — blockers, 2 — usage or input error.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { type Brief, checkBrief, type BriefCheckResult } from './brief-check';
|
|
13
|
+
|
|
14
|
+
function parseArgs(argv: string[]): Record<string, string | true> {
|
|
15
|
+
const args: Record<string, string | true> = {};
|
|
16
|
+
for (let i = 0; i < argv.length; i++) {
|
|
17
|
+
const key = argv[i];
|
|
18
|
+
if (!key.startsWith('--')) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const next = argv[i + 1];
|
|
22
|
+
if (next === undefined || next.startsWith('--')) {
|
|
23
|
+
args[key.slice(2)] = true;
|
|
24
|
+
} else {
|
|
25
|
+
args[key.slice(2)] = next;
|
|
26
|
+
i++;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return args;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readText(path: string): string {
|
|
33
|
+
return readFileSync(resolve(path), 'utf8').replace(/^/, '');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function printReport({ readiness, scenarios }: BriefCheckResult) {
|
|
37
|
+
const lines: string[] = [];
|
|
38
|
+
lines.push(`Этап: ${readiness.stage}${readiness.ready ? ' — блокеров нет' : ''}`);
|
|
39
|
+
lines.push(`Диагностики: ошибок ${readiness.diagnostics.errors}, предупреждений ${readiness.diagnostics.warnings}`);
|
|
40
|
+
lines.push(
|
|
41
|
+
`Покрытие: требования ${readiness.coverage.expressiveness}, инварианты ${readiness.coverage.invariants}, различения ${readiness.coverage.distinctions}`
|
|
42
|
+
);
|
|
43
|
+
lines.push(
|
|
44
|
+
`Сценарии: прошло ${readiness.scenarios.passed}, провалено ${readiness.scenarios.failed}, не хватает ${readiness.scenarios.missing}`
|
|
45
|
+
);
|
|
46
|
+
if (scenarios.length > 0) {
|
|
47
|
+
lines.push('', 'Матрица приёмки:');
|
|
48
|
+
for (const scenario of scenarios) {
|
|
49
|
+
lines.push(
|
|
50
|
+
` ${scenario.passed ? '✓' : '✗'} ${scenario.id} ${scenario.title}${scenario.error ? ` — ${scenario.error}` : ''}`
|
|
51
|
+
);
|
|
52
|
+
for (const check of scenario.checks) {
|
|
53
|
+
lines.push(
|
|
54
|
+
` ${check.passed ? '✓' : '✗'} ${check.target}: ожидалось ${check.expected}, получено ${check.actual}`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (readiness.blockers.length > 0) {
|
|
60
|
+
lines.push('', 'Блокеры:', ...readiness.blockers.map(item => ` - ${item}`));
|
|
61
|
+
}
|
|
62
|
+
if (readiness.warnings.length > 0) {
|
|
63
|
+
lines.push('', 'Предупреждения:', ...readiness.warnings.map(item => ` - ${item}`));
|
|
64
|
+
}
|
|
65
|
+
lines.push('', `Дальше: ${readiness.next}`);
|
|
66
|
+
console.log(lines.join('\n'));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function main() {
|
|
70
|
+
const args = parseArgs(process.argv.slice(2));
|
|
71
|
+
if (typeof args.session !== 'string' || typeof args.brief !== 'string') {
|
|
72
|
+
console.error(
|
|
73
|
+
'Usage: tsx skills/scripts/check-brief.ts --session <session.json> --brief <brief.json> [--base <session.json>] [--review] [--json]'
|
|
74
|
+
);
|
|
75
|
+
process.exit(2);
|
|
76
|
+
}
|
|
77
|
+
const result = await checkBrief({
|
|
78
|
+
session: readText(args.session),
|
|
79
|
+
brief: JSON.parse(readText(args.brief)) as Brief,
|
|
80
|
+
base: typeof args.base === 'string' ? readText(args.base) : undefined,
|
|
81
|
+
mode: args.review ? 'review' : 'build'
|
|
82
|
+
});
|
|
83
|
+
if (args.json) {
|
|
84
|
+
console.log(JSON.stringify(result, null, 2));
|
|
85
|
+
} else {
|
|
86
|
+
printReport(result);
|
|
87
|
+
}
|
|
88
|
+
process.exit(result.readiness.ready ? 0 : 1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
main().catch((error: unknown) => {
|
|
92
|
+
console.error(error instanceof Error ? error.message : error);
|
|
93
|
+
process.exit(2);
|
|
94
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Writes the `rstool` entry skill stub into a project skills directory.
|
|
4
|
+
*
|
|
5
|
+
* The stub holds only `name`, `description` and the path to the real router skill,
|
|
6
|
+
* so nothing has to be re-synced after a package update. Which directory a host loads
|
|
7
|
+
* is the agent's call (skills/INSTALL.md), so there is no default `--dir`.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* rstool-skills install --dir <skills-dir>... [--source <router SKILL.md>] [--target <path>]
|
|
11
|
+
* rstool-skills stub [--source <router SKILL.md>] [--target <path>] (prints the stub)
|
|
12
|
+
*
|
|
13
|
+
* Defaults: --source — router skill of this package; --target — its path from the
|
|
14
|
+
* current directory, or node_modules/@rsconcept/rstool/skills/rstool/SKILL.md when
|
|
15
|
+
* the package lies outside it.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const ROUTER = fileURLToPath(new URL('../rstool/SKILL.md', import.meta.url));
|
|
22
|
+
|
|
23
|
+
/** Extracts the YAML front matter `description` block (folded `>-` or single line) verbatim. */
|
|
24
|
+
export function readDescription(skillText) {
|
|
25
|
+
const match = /^---\n([\s\S]*?)\n---/.exec(skillText.replace(/\r\n/g, '\n'));
|
|
26
|
+
if (!match) {
|
|
27
|
+
throw new Error('SKILL.md has no front matter');
|
|
28
|
+
}
|
|
29
|
+
const lines = match[1].split('\n');
|
|
30
|
+
const start = lines.findIndex(line => line.startsWith('description:'));
|
|
31
|
+
if (start < 0) {
|
|
32
|
+
throw new Error('SKILL.md has no description');
|
|
33
|
+
}
|
|
34
|
+
const block = [lines[start]];
|
|
35
|
+
for (const line of lines.slice(start + 1)) {
|
|
36
|
+
if (!line.startsWith(' ')) {
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
block.push(line);
|
|
40
|
+
}
|
|
41
|
+
return block.join('\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Renders the stub skill pointing at `targetPath` (written as is, use forward slashes). */
|
|
45
|
+
export function renderStub(skillText, targetPath) {
|
|
46
|
+
return [
|
|
47
|
+
'---',
|
|
48
|
+
'name: rstool',
|
|
49
|
+
readDescription(skillText),
|
|
50
|
+
'---',
|
|
51
|
+
'',
|
|
52
|
+
'# rstool',
|
|
53
|
+
'',
|
|
54
|
+
`Заглушка для автосрабатывания. Прочитай \`${targetPath}\` и следуй ему.`,
|
|
55
|
+
'',
|
|
56
|
+
'Содержимое скилла — только там; эта заглушка его не повторяет и обновлять её после обновления пакета не нужно.',
|
|
57
|
+
''
|
|
58
|
+
].join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseArgs(argv) {
|
|
62
|
+
const args = { dirs: [], source: undefined, target: undefined };
|
|
63
|
+
for (let i = 0; i < argv.length; i++) {
|
|
64
|
+
const value = argv[i + 1];
|
|
65
|
+
if (argv[i] === '--dir') {
|
|
66
|
+
args.dirs.push(value);
|
|
67
|
+
i++;
|
|
68
|
+
} else if (argv[i] === '--source') {
|
|
69
|
+
args.source = value;
|
|
70
|
+
i++;
|
|
71
|
+
} else if (argv[i] === '--target') {
|
|
72
|
+
args.target = value;
|
|
73
|
+
i++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return args;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function stubTarget(args, source) {
|
|
80
|
+
const fromProject = relative(process.cwd(), source).split('\\').join('/');
|
|
81
|
+
return (
|
|
82
|
+
args.target ??
|
|
83
|
+
(fromProject.startsWith('..') ? 'node_modules/@rsconcept/rstool/skills/rstool/SKILL.md' : fromProject)
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function install(args) {
|
|
88
|
+
const source = resolve(args.source ?? ROUTER);
|
|
89
|
+
const text = readFileSync(source, 'utf8');
|
|
90
|
+
const target = stubTarget(args, source);
|
|
91
|
+
for (const dir of args.dirs) {
|
|
92
|
+
const path = join(dir, 'rstool', 'SKILL.md');
|
|
93
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
94
|
+
writeFileSync(path, renderStub(text, target), 'utf8');
|
|
95
|
+
console.log(`wrote ${path} → ${target}`);
|
|
96
|
+
const legacy = join(dir, 'rstool-helper', 'SKILL.md');
|
|
97
|
+
if (existsSync(legacy)) {
|
|
98
|
+
console.log(`note: ${legacy} is the old copied skill; remove it — rstool replaces it`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
104
|
+
if (isMain) {
|
|
105
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
106
|
+
const args = parseArgs(rest);
|
|
107
|
+
if (command === 'install' && args.dirs.length > 0) {
|
|
108
|
+
install(args);
|
|
109
|
+
} else if (command === 'stub') {
|
|
110
|
+
const source = resolve(args.source ?? ROUTER);
|
|
111
|
+
process.stdout.write(renderStub(readFileSync(source, 'utf8'), stubTarget(args, source)));
|
|
112
|
+
} else {
|
|
113
|
+
console.error(
|
|
114
|
+
'Usage: rstool-skills install --dir <skills-dir>... | rstool-skills stub [--source <SKILL.md>] [--target <path>]\n' +
|
|
115
|
+
'Which skills directory to use: node_modules/@rsconcept/rstool/skills/INSTALL.md'
|
|
116
|
+
);
|
|
117
|
+
process.exit(2);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
const SCRIPT = resolve(import.meta.dirname, 'rstool-skills.mjs');
|
|
9
|
+
const ROUTER = resolve(import.meta.dirname, '../rstool/SKILL.md');
|
|
10
|
+
const WORKSPACE = resolve(import.meta.dirname, '../../../..');
|
|
11
|
+
const STUBS = ['.agents/skills/rstool/SKILL.md', '.claude/skills/rstool/SKILL.md'];
|
|
12
|
+
|
|
13
|
+
function renderStub(target: string): string {
|
|
14
|
+
return execFileSync('node', [SCRIPT, 'stub', '--source', ROUTER, '--target', target], { encoding: 'utf8' });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('rstool skill stubs', () => {
|
|
18
|
+
it.each(STUBS)('workspace %s matches the router skill', stub => {
|
|
19
|
+
const path = join(WORKSPACE, stub);
|
|
20
|
+
if (!existsSync(path)) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
expect(readFileSync(path, 'utf8')).toBe(renderStub('rsconcept/rstool/skills/rstool/SKILL.md'));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('install requires an explicit skills directory', () => {
|
|
27
|
+
const project = mkdtempSync(join(tmpdir(), 'rstool-skills-'));
|
|
28
|
+
try {
|
|
29
|
+
expect(() => execFileSync('node', [SCRIPT, 'install'], { cwd: project, stdio: 'pipe' })).toThrow();
|
|
30
|
+
expect(existsSync(join(project, '.claude'))).toBe(false);
|
|
31
|
+
expect(existsSync(join(project, '.agents'))).toBe(false);
|
|
32
|
+
} finally {
|
|
33
|
+
rmSync(project, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('installs a stub that points into node_modules', () => {
|
|
38
|
+
const project = mkdtempSync(join(tmpdir(), 'rstool-skills-'));
|
|
39
|
+
try {
|
|
40
|
+
execFileSync('node', [SCRIPT, 'install', '--dir', 'skills'], { cwd: project, encoding: 'utf8' });
|
|
41
|
+
const stub = readFileSync(join(project, 'skills/rstool/SKILL.md'), 'utf8');
|
|
42
|
+
expect(stub).toBe(renderStub('node_modules/@rsconcept/rstool/skills/rstool/SKILL.md'));
|
|
43
|
+
expect(stub).toContain('name: rstool');
|
|
44
|
+
} finally {
|
|
45
|
+
rmSync(project, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { DiagnosticKind, expressionDiagnostic } from './diagnostic-assembly';
|
|
2
2
|
import { type RSErrorDescription, type Value } from '@rsconcept/domain/rslang';
|
|
3
|
+
import { extractGlobals } from '@rsconcept/domain/rslang/api';
|
|
4
|
+
import { normalizeValue } from '@rsconcept/domain/rslang/eval/value-api';
|
|
3
5
|
import { isBaseSet } from '@rsconcept/domain/library/rsform-api';
|
|
4
6
|
import { CstType, type RSForm } from '@rsconcept/domain/library/rsform';
|
|
5
7
|
import { RSEngine, type RSEngineServices } from '@rsconcept/domain/library/rsengine';
|
|
@@ -37,7 +39,11 @@ export class ModelAdapter {
|
|
|
37
39
|
const binding = toBasicBinding(input.value as Record<string | number, string>);
|
|
38
40
|
await engine.setBasicValue(input.target, binding);
|
|
39
41
|
} else {
|
|
40
|
-
|
|
42
|
+
// Set operations in the evaluator assume canonical form (sorted, deduplicated);
|
|
43
|
+
// agents may pass elements in arbitrary order.
|
|
44
|
+
const value = structuredClone(input.value) as Value;
|
|
45
|
+
normalizeValue(value);
|
|
46
|
+
await engine.setStructureValue(input.target, value);
|
|
41
47
|
}
|
|
42
48
|
session.updatedAt = new Date().toISOString();
|
|
43
49
|
return structuredClone(session.model);
|
|
@@ -64,6 +70,7 @@ export class ModelAdapter {
|
|
|
64
70
|
|
|
65
71
|
public evaluateExpression(session: SessionState, expression: string, cstType: CstType): EvaluationResult {
|
|
66
72
|
const engine = this.createEngine(session);
|
|
73
|
+
this.prepareReferencedValues(engine, session, expression);
|
|
67
74
|
const result = engine.evaluateExpression(expression, cstType);
|
|
68
75
|
const status =
|
|
69
76
|
result.value === null
|
|
@@ -120,6 +127,32 @@ export class ModelAdapter {
|
|
|
120
127
|
return engine;
|
|
121
128
|
}
|
|
122
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Computes values of inferrable constituents referenced by a scratch expression (directly or
|
|
132
|
+
* through `F#` / `P#` bodies). A fresh engine only holds interpreted `X#` / `C#` / `S#` values.
|
|
133
|
+
*/
|
|
134
|
+
private prepareReferencedValues(engine: RSEngine, session: SessionState, expression: string): void {
|
|
135
|
+
const schema = engine.schema;
|
|
136
|
+
if (!schema) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const globals = extractGlobals(expression);
|
|
140
|
+
const referenced = session.items.filter(item => globals.has(item.alias)).map(item => item.id);
|
|
141
|
+
if (referenced.length === 0) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const required = new Set([...referenced, ...schema.graph.expandAllInputs(referenced)]);
|
|
145
|
+
for (const cstID of schema.graph.topologicalOrder()) {
|
|
146
|
+
if (!required.has(cstID)) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const cst = schema.cstByID.get(cstID);
|
|
150
|
+
if (cst && isInferrable(cst.cst_type)) {
|
|
151
|
+
engine.calculateCst(cstID);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
123
156
|
private validateSetInput(session: SessionState, input: SetConstituentaValueInput): void {
|
|
124
157
|
const cst = session.items.find(item => item.id === input.target);
|
|
125
158
|
if (!cst) {
|
|
@@ -1029,6 +1029,101 @@ describe('RSToolAgent modeling semantics', () => {
|
|
|
1029
1029
|
expect(modelIssues.some(record => record.code === RSDiagnosticCode.modelAxiomFalse)).toBe(true);
|
|
1030
1030
|
});
|
|
1031
1031
|
|
|
1032
|
+
function buildGraphScratch(tool: RSToolAgent, sessionId: string) {
|
|
1033
|
+
tool.applySchemaPatch(
|
|
1034
|
+
{
|
|
1035
|
+
items: [
|
|
1036
|
+
{ alias: 'X1' },
|
|
1037
|
+
{ alias: 'S1', definitionFormal: 'ℬ(X1×X1)' },
|
|
1038
|
+
{ alias: 'D1', definitionFormal: 'Pr2,1(S1)' },
|
|
1039
|
+
{ alias: 'D2', definitionFormal: 'S1\\D1' },
|
|
1040
|
+
{ alias: 'F1', definitionFormal: '[α∈X1] Pr2(Fi1[{α}](D2))' }
|
|
1041
|
+
]
|
|
1042
|
+
},
|
|
1043
|
+
sessionId
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
it('normalizes unsorted structure values before evaluation', async () => {
|
|
1048
|
+
const tool = new RSToolAgent();
|
|
1049
|
+
const session = tool.createSession();
|
|
1050
|
+
buildGraphScratch(tool, session.sessionId);
|
|
1051
|
+
|
|
1052
|
+
await tool.setModelValues(
|
|
1053
|
+
{
|
|
1054
|
+
set: [
|
|
1055
|
+
{ target: 1, value: { 0: 'a', 1: 'b', 2: 'c' } },
|
|
1056
|
+
{
|
|
1057
|
+
target: 2,
|
|
1058
|
+
value: [
|
|
1059
|
+
[TUPLE_ID, 1, 2],
|
|
1060
|
+
[TUPLE_ID, 0, 2],
|
|
1061
|
+
[TUPLE_ID, 0, 1],
|
|
1062
|
+
[TUPLE_ID, 1, 0],
|
|
1063
|
+
[TUPLE_ID, 0, 1]
|
|
1064
|
+
]
|
|
1065
|
+
}
|
|
1066
|
+
]
|
|
1067
|
+
},
|
|
1068
|
+
session.sessionId
|
|
1069
|
+
);
|
|
1070
|
+
|
|
1071
|
+
const stored = tool.getModelState(session.sessionId).items.find(item => item.id === 2);
|
|
1072
|
+
expect(stored?.value).toEqual([
|
|
1073
|
+
[TUPLE_ID, 0, 1],
|
|
1074
|
+
[TUPLE_ID, 0, 2],
|
|
1075
|
+
[TUPLE_ID, 1, 0],
|
|
1076
|
+
[TUPLE_ID, 1, 2]
|
|
1077
|
+
]);
|
|
1078
|
+
|
|
1079
|
+
// S1 \ inverse(S1): (0,1) and (1,0) cancel out
|
|
1080
|
+
const result = tool.evaluate({ constituentId: 4 }, session.sessionId);
|
|
1081
|
+
expect(result.status).toBe(EvalStatus.HAS_DATA);
|
|
1082
|
+
expect(result.value).toEqual([
|
|
1083
|
+
[TUPLE_ID, 0, 2],
|
|
1084
|
+
[TUPLE_ID, 1, 2]
|
|
1085
|
+
]);
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
it('evaluates scratch expressions that reference derived constituents', async () => {
|
|
1089
|
+
const tool = new RSToolAgent();
|
|
1090
|
+
const session = tool.createSession();
|
|
1091
|
+
buildGraphScratch(tool, session.sessionId);
|
|
1092
|
+
await tool.setModelValues(
|
|
1093
|
+
{
|
|
1094
|
+
set: [
|
|
1095
|
+
{ target: 1, value: { 0: 'a', 1: 'b', 2: 'c' } },
|
|
1096
|
+
{
|
|
1097
|
+
target: 2,
|
|
1098
|
+
value: [
|
|
1099
|
+
[TUPLE_ID, 0, 1],
|
|
1100
|
+
[TUPLE_ID, 0, 2],
|
|
1101
|
+
[TUPLE_ID, 1, 0],
|
|
1102
|
+
[TUPLE_ID, 1, 2]
|
|
1103
|
+
]
|
|
1104
|
+
}
|
|
1105
|
+
]
|
|
1106
|
+
},
|
|
1107
|
+
session.sessionId
|
|
1108
|
+
);
|
|
1109
|
+
|
|
1110
|
+
const direct = tool.evaluate({ expression: 'card(D2)', cstType: CstType.TERM }, session.sessionId);
|
|
1111
|
+
expect(direct.success).toBe(true);
|
|
1112
|
+
expect(direct.value).toBe(2);
|
|
1113
|
+
|
|
1114
|
+
// F1 body references D2, which must be computed before the call
|
|
1115
|
+
const viaFunction = tool.evaluate(
|
|
1116
|
+
{ expression: 'card(red(I{F1[ξ] | ξ:∈Pr1(D2)}))', cstType: CstType.TERM },
|
|
1117
|
+
session.sessionId
|
|
1118
|
+
);
|
|
1119
|
+
expect(viaFunction.success).toBe(true);
|
|
1120
|
+
expect(viaFunction.value).toBe(1);
|
|
1121
|
+
|
|
1122
|
+
const logic = tool.evaluate({ expression: 'D2⊆S1', cstType: CstType.AXIOM }, session.sessionId);
|
|
1123
|
+
expect(logic.success).toBe(true);
|
|
1124
|
+
expect(logic.value).toBe(1);
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1032
1127
|
it('throws when evaluate input is incomplete', () => {
|
|
1033
1128
|
const tool = new RSToolAgent();
|
|
1034
1129
|
const session = tool.createSession();
|
|
@@ -5,14 +5,53 @@ import { describe, expect, it } from 'vitest';
|
|
|
5
5
|
import { CstType, EvalStatus, RSToolWrapperClient } from '../index';
|
|
6
6
|
|
|
7
7
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
8
|
+
const tsxCli = fileURLToPath(import.meta.resolve('tsx/cli'));
|
|
9
|
+
const wrapperEntry = resolve(packageRoot, 'src/wrapper/stdio-wrapper.ts');
|
|
8
10
|
|
|
9
11
|
describe('RSToolWrapperClient integration', () => {
|
|
12
|
+
it('rejects readiness and calls when the wrapper command cannot start', async () => {
|
|
13
|
+
const client = new RSToolWrapperClient({
|
|
14
|
+
command: resolve(packageRoot, 'definitely-missing-rstool-wrapper-binary'),
|
|
15
|
+
args: [],
|
|
16
|
+
cwd: packageRoot
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
await expect(client.waitUntilReady()).rejects.toThrow(/ENOENT/);
|
|
20
|
+
await expect(client.call('methods')).rejects.toThrow(/ENOENT/);
|
|
21
|
+
} finally {
|
|
22
|
+
await client.close();
|
|
23
|
+
}
|
|
24
|
+
}, 30_000);
|
|
25
|
+
|
|
26
|
+
it('rejects outstanding calls when the wrapper exits before responding', async () => {
|
|
27
|
+
const client = new RSToolWrapperClient({
|
|
28
|
+
command: process.execPath,
|
|
29
|
+
args: ['-e', 'process.stdin.resume(); setTimeout(() => process.exit(3), 200)'],
|
|
30
|
+
cwd: packageRoot
|
|
31
|
+
});
|
|
32
|
+
try {
|
|
33
|
+
await expect(client.call('methods')).rejects.toThrow(/exited before responding \(code 3/);
|
|
34
|
+
} finally {
|
|
35
|
+
await client.close();
|
|
36
|
+
}
|
|
37
|
+
}, 30_000);
|
|
38
|
+
|
|
39
|
+
it('starts the default npm wrapper without a shell', async () => {
|
|
40
|
+
const client = new RSToolWrapperClient({ cwd: packageRoot });
|
|
41
|
+
try {
|
|
42
|
+
await client.waitUntilReady();
|
|
43
|
+
const methods = await client.call<string[]>('methods');
|
|
44
|
+
expect(methods).toContain('applySchemaPatch');
|
|
45
|
+
} finally {
|
|
46
|
+
await client.close();
|
|
47
|
+
}
|
|
48
|
+
}, 30_000);
|
|
49
|
+
|
|
10
50
|
it('runs createSession, patch, model, and evaluate over stdio', async () => {
|
|
11
51
|
const client = new RSToolWrapperClient({
|
|
12
|
-
command:
|
|
13
|
-
args: [
|
|
14
|
-
cwd: packageRoot
|
|
15
|
-
shell: true
|
|
52
|
+
command: process.execPath,
|
|
53
|
+
args: [tsxCli, wrapperEntry],
|
|
54
|
+
cwd: packageRoot
|
|
16
55
|
});
|
|
17
56
|
|
|
18
57
|
try {
|
package/src/wrapper/client.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { spawn, type ChildProcessByStdio } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
2
4
|
import readline from 'node:readline';
|
|
3
5
|
import { type Readable, type Writable } from 'node:stream';
|
|
4
6
|
|
|
@@ -16,14 +18,38 @@ export interface WrapperResponse<T = unknown> {
|
|
|
16
18
|
|
|
17
19
|
/** Options for spawning the `rstool-wrapper` child process. */
|
|
18
20
|
export interface RSToolWrapperClientOptions {
|
|
19
|
-
/** Executable to spawn. Default: `npm`. */
|
|
21
|
+
/** Executable to spawn. Default: `npm`. Passed as argv, not a shell string. */
|
|
20
22
|
command?: string;
|
|
21
23
|
/** Arguments passed to `command`. Default: `['run', 'wrapper']`. */
|
|
22
24
|
args?: string[];
|
|
23
25
|
/** Working directory for the child process. Default: `process.cwd()`. */
|
|
24
26
|
cwd?: string;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Rewrite Windows npm/npx shims to `node <npm-cli.js>` so spawn() never needs a shell.
|
|
31
|
+
* Node 20+ refuses to exec `.cmd` files without `shell: true` (CVE-2024-27980).
|
|
32
|
+
*/
|
|
33
|
+
function resolveSpawn(command: string, args: string[]): { command: string; args: string[] } {
|
|
34
|
+
if (process.platform !== 'win32') {
|
|
35
|
+
return { command, args };
|
|
36
|
+
}
|
|
37
|
+
const base = (command.replaceAll('\\', '/').split('/').pop() ?? command).toLowerCase();
|
|
38
|
+
if (base === 'npm' || base === 'npm.cmd') {
|
|
39
|
+
return { command: process.execPath, args: [bundledNpmBin('npm-cli.js'), ...args] };
|
|
40
|
+
}
|
|
41
|
+
if (base === 'npx' || base === 'npx.cmd') {
|
|
42
|
+
return { command: process.execPath, args: [bundledNpmBin('npx-cli.js'), ...args] };
|
|
43
|
+
}
|
|
44
|
+
return { command, args };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function bundledNpmBin(bin: 'npm-cli.js' | 'npx-cli.js'): string {
|
|
48
|
+
const cli = join(dirname(process.execPath), 'node_modules', 'npm', 'bin', bin);
|
|
49
|
+
if (!existsSync(cli)) {
|
|
50
|
+
throw new Error(`Cannot resolve ${bin} next to Node at ${process.execPath}`);
|
|
51
|
+
}
|
|
52
|
+
return cli;
|
|
27
53
|
}
|
|
28
54
|
|
|
29
55
|
/**
|
|
@@ -34,16 +60,20 @@ export interface RSToolWrapperClientOptions {
|
|
|
34
60
|
export class RSToolWrapperClient {
|
|
35
61
|
private process: ChildProcessByStdio<Writable, Readable, null>;
|
|
36
62
|
private input: readline.Interface;
|
|
37
|
-
private pending = new Map<string,
|
|
63
|
+
private pending = new Map<string, PendingCall>();
|
|
38
64
|
private requestCounter = 1;
|
|
65
|
+
/** Set once the child cannot start or has exited; every later request fails with this error. */
|
|
66
|
+
private failure: Error | null = null;
|
|
67
|
+
private closing = false;
|
|
39
68
|
|
|
40
69
|
/**
|
|
41
70
|
* @param options - Spawn configuration; defaults run `npm run wrapper` in the current directory.
|
|
42
71
|
*/
|
|
43
72
|
public constructor(options: RSToolWrapperClientOptions = {}) {
|
|
44
|
-
|
|
73
|
+
const resolved = resolveSpawn(options.command ?? 'npm', options.args ?? ['run', 'wrapper']);
|
|
74
|
+
this.process = spawn(resolved.command, resolved.args, {
|
|
45
75
|
cwd: options.cwd ?? process.cwd(),
|
|
46
|
-
|
|
76
|
+
windowsHide: true,
|
|
47
77
|
stdio: ['pipe', 'pipe', 'inherit']
|
|
48
78
|
});
|
|
49
79
|
this.input = readline.createInterface({
|
|
@@ -51,9 +81,25 @@ export class RSToolWrapperClient {
|
|
|
51
81
|
crlfDelay: Infinity
|
|
52
82
|
});
|
|
53
83
|
this.input.on('line', line => this.handleLine(line));
|
|
84
|
+
|
|
85
|
+
// Without these listeners a failed spawn (ENOENT, EACCES) or a broken stdin pipe
|
|
86
|
+
// surfaces as an uncaught exception instead of a rejected promise.
|
|
87
|
+
this.process.on('error', error => this.fail(error));
|
|
88
|
+
this.process.stdin.on('error', error => this.fail(error));
|
|
89
|
+
this.process.on('exit', (code, signal) => {
|
|
90
|
+
if (this.closing) {
|
|
91
|
+
this.fail(new Error('Wrapper closed'));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.fail(new Error(`Wrapper exited before responding (code ${code ?? 'null'}, signal ${signal ?? 'null'})`));
|
|
95
|
+
});
|
|
54
96
|
}
|
|
55
97
|
|
|
56
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Block until the wrapper emits its initial `{ ready: true }` event.
|
|
100
|
+
*
|
|
101
|
+
* @throws When the wrapper process cannot be started or exits before becoming ready.
|
|
102
|
+
*/
|
|
57
103
|
public async waitUntilReady(): Promise<void> {
|
|
58
104
|
for (;;) {
|
|
59
105
|
const line = await this.readOneEvent();
|
|
@@ -74,13 +120,16 @@ export class RSToolWrapperClient {
|
|
|
74
120
|
*
|
|
75
121
|
* @param method - Stdio method name (matches {@link RSToolAgentContract} operations).
|
|
76
122
|
* @param params - Method parameters object.
|
|
77
|
-
* @throws When the wrapper responds with `ok: false
|
|
123
|
+
* @throws When the wrapper responds with `ok: false`, cannot be started, or exits before responding.
|
|
78
124
|
*/
|
|
79
125
|
public async call<T>(method: string, params: unknown = {}): Promise<T> {
|
|
126
|
+
if (this.failure) {
|
|
127
|
+
throw this.failure;
|
|
128
|
+
}
|
|
80
129
|
const id = String(this.requestCounter++);
|
|
81
130
|
const payload = JSON.stringify({ id, method, params });
|
|
82
|
-
const responsePromise = new Promise<WrapperResponse>(resolve => {
|
|
83
|
-
this.pending.set(id, resolve);
|
|
131
|
+
const responsePromise = new Promise<WrapperResponse>((resolve, reject) => {
|
|
132
|
+
this.pending.set(id, { resolve, reject });
|
|
84
133
|
});
|
|
85
134
|
this.process.stdin.write(`${payload}\n`);
|
|
86
135
|
const response = await responsePromise;
|
|
@@ -92,14 +141,20 @@ export class RSToolWrapperClient {
|
|
|
92
141
|
|
|
93
142
|
/** Close stdin and terminate the wrapper process. */
|
|
94
143
|
public async close(): Promise<void> {
|
|
144
|
+
this.closing = true;
|
|
95
145
|
this.input.close();
|
|
96
|
-
this.process.stdin.
|
|
97
|
-
|
|
146
|
+
if (!this.process.stdin.destroyed) {
|
|
147
|
+
this.process.stdin.end();
|
|
148
|
+
}
|
|
149
|
+
if (this.process.exitCode === null && !this.process.killed) {
|
|
98
150
|
this.process.kill();
|
|
99
151
|
}
|
|
100
152
|
}
|
|
101
153
|
|
|
102
154
|
private async readOneEvent(): Promise<string> {
|
|
155
|
+
if (this.failure) {
|
|
156
|
+
throw this.failure;
|
|
157
|
+
}
|
|
103
158
|
return new Promise((resolve, reject) => {
|
|
104
159
|
const onLine = (line: string) => {
|
|
105
160
|
cleanup();
|
|
@@ -107,17 +162,36 @@ export class RSToolWrapperClient {
|
|
|
107
162
|
};
|
|
108
163
|
const onExit = () => {
|
|
109
164
|
cleanup();
|
|
110
|
-
reject(new Error('Wrapper exited before ready'));
|
|
165
|
+
reject(this.failure ?? new Error('Wrapper exited before ready'));
|
|
166
|
+
};
|
|
167
|
+
const onError = (error: Error) => {
|
|
168
|
+
cleanup();
|
|
169
|
+
reject(error);
|
|
111
170
|
};
|
|
112
171
|
const cleanup = () => {
|
|
113
172
|
this.input.off('line', onLine);
|
|
114
173
|
this.process.off('exit', onExit);
|
|
174
|
+
this.process.off('error', onError);
|
|
115
175
|
};
|
|
116
176
|
this.input.on('line', onLine);
|
|
117
177
|
this.process.on('exit', onExit);
|
|
178
|
+
this.process.on('error', onError);
|
|
118
179
|
});
|
|
119
180
|
}
|
|
120
181
|
|
|
182
|
+
/** Records the terminal failure and rejects every outstanding request. */
|
|
183
|
+
private fail(error: Error): void {
|
|
184
|
+
if (this.failure) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
this.failure = error;
|
|
188
|
+
const outstanding = [...this.pending.values()];
|
|
189
|
+
this.pending.clear();
|
|
190
|
+
for (const entry of outstanding) {
|
|
191
|
+
entry.reject(error);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
121
195
|
private handleLine(line: string): void {
|
|
122
196
|
let parsed: WrapperResponse;
|
|
123
197
|
try {
|
|
@@ -129,11 +203,16 @@ export class RSToolWrapperClient {
|
|
|
129
203
|
return;
|
|
130
204
|
}
|
|
131
205
|
const id = String(parsed.id);
|
|
132
|
-
const
|
|
133
|
-
if (!
|
|
206
|
+
const entry = this.pending.get(id);
|
|
207
|
+
if (!entry) {
|
|
134
208
|
return;
|
|
135
209
|
}
|
|
136
210
|
this.pending.delete(id);
|
|
137
|
-
|
|
211
|
+
entry.resolve(parsed);
|
|
138
212
|
}
|
|
139
213
|
}
|
|
214
|
+
|
|
215
|
+
interface PendingCall {
|
|
216
|
+
resolve: (value: WrapperResponse) => void;
|
|
217
|
+
reject: (reason: Error) => void;
|
|
218
|
+
}
|