@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.
Files changed (126) hide show
  1. package/README.md +7 -7
  2. package/dist/index.js +1 -1
  3. package/dist/mappers/model-adapter.d.ts +6 -2
  4. package/dist/mappers/model-adapter.js +28 -3
  5. package/dist/mappers/model-adapter.js.map +1 -1
  6. package/dist/mappers/schema-adapter.d.ts +1 -2
  7. package/dist/mappers/schema-adapter.js +1 -1
  8. package/dist/mappers/types.d.ts +4 -5
  9. package/dist/models/index.js +1 -1
  10. package/dist/models/rstool-agent.js +1 -2
  11. package/dist/{models-DG2uTaV_.js → models-D06g8LId.js} +6 -5
  12. package/dist/{models-DG2uTaV_.js.map → models-D06g8LId.js.map} +1 -1
  13. package/dist/{rsform-builder-DKwZfJvN.js → rsform-builder-BlXEe5Ow.js} +2 -2
  14. package/dist/{rsform-builder-DKwZfJvN.js.map → rsform-builder-BlXEe5Ow.js.map} +1 -1
  15. package/dist/session/session-store.d.ts +3 -3
  16. package/dist/session/session-store.js +1 -2
  17. package/dist/wrapper/client.d.ts +15 -9
  18. package/dist/wrapper/client.js +79 -12
  19. package/dist/wrapper/client.js.map +1 -1
  20. package/dist/wrapper/stdio-wrapper.d.ts +1 -1
  21. package/dist/wrapper/stdio-wrapper.js +18 -10
  22. package/dist/wrapper/stdio-wrapper.js.map +1 -1
  23. package/docs/BRIEF.md +93 -0
  24. package/docs/CONCEPTUAL-SCHEMA.md +17 -2
  25. package/docs/CONSTRUCTIONS.md +384 -0
  26. package/docs/DIAGNOSTICS.md +9 -8
  27. package/docs/DOMAIN.md +12 -0
  28. package/docs/METHOD.md +208 -0
  29. package/docs/MODEL-TESTING.md +48 -0
  30. package/docs/PORTAL-API.md +38 -285
  31. package/docs/PORTAL-MODELS.md +24 -0
  32. package/docs/PORTAL-OSS.md +56 -0
  33. package/docs/PORTAL-PUBLISH.md +39 -0
  34. package/docs/PORTAL-READ.md +301 -0
  35. package/docs/README.md +8 -4
  36. package/docs/REQUIREMENTS.md +61 -0
  37. package/docs/SYNTAX.md +5 -4
  38. package/docs/TYPIFICATION.md +12 -3
  39. package/docs/brief.schema.json +173 -0
  40. package/examples/README.md +16 -2
  41. package/examples/access-control/NOTES.md +23 -0
  42. package/examples/access-control/anti/collapsed.json +439 -0
  43. package/examples/access-control/anti/weakened.json +439 -0
  44. package/examples/access-control/brief-v1.json +231 -0
  45. package/examples/access-control/brief.json +310 -0
  46. package/examples/access-control/build-rsform.ts +151 -0
  47. package/examples/access-control/requirements-v1.md +9 -0
  48. package/examples/access-control/requirements-v2.md +4 -0
  49. package/examples/access-control/rsform-session-v1.json +439 -0
  50. package/examples/access-control/rsform-session.json +523 -0
  51. package/examples/audit-diagnostics.ts +9 -1
  52. package/examples/briefs.test.ts +126 -0
  53. package/examples/clinic-booking/NOTES.md +24 -0
  54. package/examples/clinic-booking/anti/collapsed.json +615 -0
  55. package/examples/clinic-booking/anti/species-as-genus.json +568 -0
  56. package/examples/clinic-booking/brief-v1.json +332 -0
  57. package/examples/clinic-booking/brief.json +405 -0
  58. package/examples/clinic-booking/build-rsform.ts +174 -0
  59. package/examples/clinic-booking/requirements-v1.md +9 -0
  60. package/examples/clinic-booking/requirements-v2.md +4 -0
  61. package/examples/clinic-booking/rsform-session-v1.json +615 -0
  62. package/examples/clinic-booking/rsform-session.json +700 -0
  63. package/examples/diagnostics-utils.ts +26 -0
  64. package/examples/kinship/brief.json +337 -0
  65. package/examples/kinship/build-rsform.ts +7 -4
  66. package/examples/kinship/rsform-session.json +20 -63
  67. package/examples/kinship/rsmodel-session.json +14 -14
  68. package/examples/kinship-subordination/NOTES.md +49 -0
  69. package/examples/kinship-subordination/brief.json +161 -0
  70. package/examples/kinship-subordination/build-rsform.ts +100 -0
  71. package/examples/kinship-subordination/rsform-session.json +795 -0
  72. package/examples/library-loans/NOTES.md +46 -0
  73. package/examples/library-loans/anti/collapsed.json +481 -0
  74. package/examples/library-loans/anti/species-as-genus.json +334 -0
  75. package/examples/library-loans/anti/uncovered.json +406 -0
  76. package/examples/library-loans/anti/weakened.json +464 -0
  77. package/examples/library-loans/brief-v1.json +340 -0
  78. package/examples/library-loans/brief.json +502 -0
  79. package/examples/library-loans/build-rsform.ts +239 -0
  80. package/examples/library-loans/requirements-v1.md +11 -0
  81. package/examples/library-loans/requirements-v2.md +5 -0
  82. package/examples/library-loans/rsform-session-v1.json +481 -0
  83. package/examples/library-loans/rsform-session.json +652 -0
  84. package/examples/library-loans/source-markup.md +18 -0
  85. package/examples/movd/brief.json +220 -0
  86. package/examples/movd/build-rsform.ts +5 -2
  87. package/examples/movd/rsform-session.json +14 -46
  88. package/examples/subordination/NOTES.md +59 -0
  89. package/examples/subordination/anti/collapsed.json +556 -0
  90. package/examples/subordination/anti/single-relation.json +125 -0
  91. package/examples/subordination/anti/transitive.json +573 -0
  92. package/examples/subordination/anti/weakened.json +539 -0
  93. package/examples/subordination/brief.json +386 -0
  94. package/examples/subordination/build-rsform.ts +63 -0
  95. package/examples/subordination/constants.ts +115 -0
  96. package/examples/subordination/rsform-session.json +556 -0
  97. package/examples/subordination/source-markup.md +19 -0
  98. package/examples/subordination/source.md +40 -0
  99. package/package.json +29 -20
  100. package/skills/INSTALL.md +28 -29
  101. package/skills/README.md +25 -10
  102. package/skills/rs-core/SKILL.md +56 -0
  103. package/skills/rs-explain/SKILL.md +28 -0
  104. package/skills/rs-explicate/SKILL.md +71 -0
  105. package/skills/rs-interpret/SKILL.md +67 -0
  106. package/skills/rs-portal/SKILL.md +51 -0
  107. package/skills/rs-review/SKILL.md +79 -0
  108. package/skills/rs-scope/SKILL.md +67 -0
  109. package/skills/rs-synthesis/SKILL.md +52 -0
  110. package/skills/rstool/SKILL.md +70 -0
  111. package/skills/rstool-helper/GUIDE.md +18 -272
  112. package/skills/rstool-helper/SKILL.md +8 -55
  113. package/skills/{rstool-helper → rstool-runtime}/EXAMPLES.md +40 -12
  114. package/skills/{rstool-helper → rstool-runtime}/REFERENCE.md +28 -7
  115. package/skills/rstool-runtime/SKILL.md +96 -0
  116. package/skills/scripts/brief-check.test.ts +165 -0
  117. package/skills/scripts/brief-check.ts +826 -0
  118. package/skills/scripts/check-brief.ts +94 -0
  119. package/skills/scripts/rstool-skills.mjs +119 -0
  120. package/skills/scripts/skill-stubs.test.ts +48 -0
  121. package/src/mappers/model-adapter.ts +34 -1
  122. package/src/models/rstool-agent.test.ts +95 -0
  123. package/src/wrapper/client.test.ts +43 -4
  124. package/src/wrapper/client.ts +95 -16
  125. package/src/wrapper/stdio-handler.test.ts +43 -0
  126. 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
- await engine.setStructureValue(input.target, input.value as Value);
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: 'npx',
13
- args: ['tsx', resolve(packageRoot, 'src/wrapper/stdio-wrapper.ts')],
14
- cwd: packageRoot,
15
- shell: true
52
+ command: process.execPath,
53
+ args: [tsxCli, wrapperEntry],
54
+ cwd: packageRoot
16
55
  });
17
56
 
18
57
  try {
@@ -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
- /** Whether to run the command in a shell. Default: `true`. */
26
- shell?: boolean;
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, (value: WrapperResponse) => void>();
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
- this.process = spawn(options.command ?? 'npm', options.args ?? ['run', 'wrapper'], {
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
- shell: options.shell ?? true,
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
- /** Block until the wrapper emits its initial `{ ready: true }` event. */
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.end();
97
- if (!this.process.killed) {
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 resolver = this.pending.get(id);
133
- if (!resolver) {
206
+ const entry = this.pending.get(id);
207
+ if (!entry) {
134
208
  return;
135
209
  }
136
210
  this.pending.delete(id);
137
- resolver(parsed);
211
+ entry.resolve(parsed);
138
212
  }
139
213
  }
214
+
215
+ interface PendingCall {
216
+ resolve: (value: WrapperResponse) => void;
217
+ reject: (reason: Error) => void;
218
+ }