@lexq/cli 0.1.46 → 0.1.48

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/dist/index.js CHANGED
@@ -48,6 +48,18 @@ function getConfigPath() {
48
48
  return CONFIG_FILE;
49
49
  }
50
50
 
51
+ // src/lib/lossless-json.ts
52
+ import {
53
+ LosslessNumber,
54
+ isSafeNumber,
55
+ parse as losslessParse,
56
+ stringify as losslessStringify
57
+ } from "lossless-json";
58
+ var survivesDoubleRoundTrip = (literal) => isSafeNumber(literal);
59
+ var parseNumber = (value) => survivesDoubleRoundTrip(value) ? parseFloat(value) : new LosslessNumber(value);
60
+ var parseJson = (text) => losslessParse(text, void 0, parseNumber);
61
+ var stringifyJson = (value, space) => losslessStringify(value, void 0, space);
62
+
51
63
  // src/lib/api-client.ts
52
64
  var ApiError = class extends Error {
53
65
  constructor(statusCode, errorCode, message) {
@@ -82,7 +94,7 @@ async function doFetch(method, path, options) {
82
94
  console.log(` Content-Type: application/json`);
83
95
  if (options.body) {
84
96
  console.log("Body:");
85
- console.log(` ${JSON.stringify(options.body, null, 2)}`);
97
+ console.log(` ${stringifyJson(options.body, 2) ?? ""}`);
86
98
  }
87
99
  console.log("\n(Use without --dry-run to execute)");
88
100
  process.exit(0);
@@ -92,7 +104,7 @@ async function doFetch(method, path, options) {
92
104
  const response = await fetch(url.toString(), {
93
105
  method,
94
106
  headers,
95
- body: options.body ? JSON.stringify(options.body) : void 0
107
+ body: options.body ? stringifyJson(options.body) : void 0
96
108
  });
97
109
  if (options.verbose) {
98
110
  console.error(`\u2190 ${response.status} ${response.statusText} (${Date.now() - startTime}ms)`);
@@ -121,7 +133,7 @@ async function apiRequestWithMeta(method, path, options = {}) {
121
133
  if (response.status === 204 || contentType === "") {
122
134
  return { data: void 0, meta: null };
123
135
  }
124
- const json = await response.json();
136
+ const json = parseJson(await response.text());
125
137
  assertOk(response, json);
126
138
  return { data: json.data, meta: json.meta ?? null };
127
139
  }
@@ -129,7 +141,7 @@ async function apiRequestWithMeta(method, path, options = {}) {
129
141
  // src/lib/output.ts
130
142
  import Table from "cli-table3";
131
143
  function printJson(data) {
132
- console.log(JSON.stringify(data, null, 2));
144
+ console.log(stringifyJson(data, 2));
133
145
  }
134
146
  function printTable(headers, rows, options) {
135
147
  const table = new Table({
@@ -976,7 +988,7 @@ ${data.length} total`);
976
988
  ).action(async (opts) => {
977
989
  try {
978
990
  const globalOpts = program.opts();
979
- const body = JSON.parse(opts.json);
991
+ const body = parseJson(opts.json);
980
992
  const { data, meta } = await apiRequestWithMeta(
981
993
  "POST",
982
994
  `policy-groups/${opts.groupId}/versions/${opts.versionId}/rules`,
@@ -1012,7 +1024,7 @@ ${data.length} total`);
1012
1024
  ).action(async (opts) => {
1013
1025
  try {
1014
1026
  const globalOpts = program.opts();
1015
- const body = JSON.parse(opts.json);
1027
+ const body = parseJson(opts.json);
1016
1028
  const { data, meta } = await apiRequestWithMeta(
1017
1029
  "PUT",
1018
1030
  `policy-groups/${opts.groupId}/versions/${opts.versionId}/rules/${opts.id}`,
@@ -1938,7 +1950,7 @@ function registerAnalyticsCommands(program) {
1938
1950
  ])
1939
1951
  );
1940
1952
  console.log("\nExample request:");
1941
- console.log(JSON.stringify(data.exampleRequest, null, 2));
1953
+ console.log(stringifyJson(data.exampleRequest, 2));
1942
1954
  } else {
1943
1955
  printJson(data);
1944
1956
  }
@@ -2043,10 +2055,10 @@ function registerAnalyticsCommands(program) {
2043
2055
  `
2044
2056
  \u2500\u2500 Metric: ${data.metricSummary.targetVariable} (${data.metricSummary.aggregationType}) \u2500\u2500`
2045
2057
  );
2046
- console.log(`Baseline: ${data.metricSummary.baselineValue.toLocaleString()}`);
2047
- console.log(`Simulated: ${data.metricSummary.simulatedValue.toLocaleString()}`);
2058
+ console.log(`Baseline: ${String(data.metricSummary.baselineValue)}`);
2059
+ console.log(`Simulated: ${String(data.metricSummary.simulatedValue)}`);
2048
2060
  console.log(
2049
- `Delta: ${data.metricSummary.delta > 0 ? "+" : ""}${data.metricSummary.delta.toLocaleString()}`
2061
+ `Delta: ${signPrefix(data.metricSummary.delta)}${String(data.metricSummary.delta)}`
2050
2062
  );
2051
2063
  console.log(
2052
2064
  `Change: ${data.metricSummary.deltaPercentage > 0 ? "+" : ""}${data.metricSummary.deltaPercentage.toFixed(1)}%`
@@ -2063,7 +2075,7 @@ function registerAnalyticsCommands(program) {
2063
2075
  `Rate \u0394: ${diff.matchedRateDelta > 0 ? "+" : ""}${diff.matchedRateDelta.toFixed(1)}%`
2064
2076
  );
2065
2077
  console.log(
2066
- `Metric \u0394: ${diff.metricValueDelta > 0 ? "+" : ""}${diff.metricValueDelta.toLocaleString()}`
2078
+ `Metric \u0394: ${signPrefix(diff.metricValueDelta)}${String(diff.metricValueDelta)}`
2067
2079
  );
2068
2080
  }
2069
2081
  if (data.ruleStats?.length) {
@@ -2071,11 +2083,10 @@ function registerAnalyticsCommands(program) {
2071
2083
  printTable(
2072
2084
  ["Rule", "Matched", "Metric"],
2073
2085
  data.ruleStats.map((r) => [
2074
- r.ruleName,
2086
+ r.ruleName.length > 30 ? r.ruleName.substring(0, 30) + "\u2026" : r.ruleName,
2075
2087
  r.matchedCount.toLocaleString(),
2076
- r.metricValue.toLocaleString()
2077
- ]),
2078
- { truncate: 30 }
2088
+ String(r.metricValue)
2089
+ ])
2079
2090
  );
2080
2091
  }
2081
2092
  } else {
@@ -2186,7 +2197,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
2186
2197
  }
2187
2198
  );
2188
2199
  if (opts.output) {
2189
- const text = typeof response === "string" ? response : JSON.stringify(response, null, 2);
2200
+ const text = typeof response === "string" ? response : stringifyJson(response, 2) ?? "";
2190
2201
  writeFileSync2(opts.output, text, "utf-8");
2191
2202
  console.log(`\u2713 Exported to ${opts.output}`);
2192
2203
  } else {
@@ -2306,13 +2317,19 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
2306
2317
  }
2307
2318
  });
2308
2319
  }
2320
+ function signPrefix(value) {
2321
+ const text = String(value);
2322
+ if (text.startsWith("-")) return "";
2323
+ const significand = text.split(/[eE]/)[0] ?? "";
2324
+ return /[1-9]/.test(significand) ? "+" : "";
2325
+ }
2309
2326
  function resolveBody(opts) {
2310
2327
  if (opts.file) {
2311
2328
  const raw = readFileSync2(opts.file, "utf-8");
2312
- return JSON.parse(raw);
2329
+ return parseJson(raw);
2313
2330
  }
2314
2331
  if (opts.json) {
2315
- return JSON.parse(opts.json);
2332
+ return parseJson(opts.json);
2316
2333
  }
2317
2334
  return {};
2318
2335
  }
@@ -3200,8 +3217,8 @@ function createCallApiFromConfig() {
3200
3217
  headers: { "X-API-KEY": config.apiKey },
3201
3218
  body: formData
3202
3219
  });
3203
- const data2 = await response.json();
3204
- return { content: [{ type: "text", text: JSON.stringify(data2, null, 2) }] };
3220
+ const data2 = parseJson(await response.text());
3221
+ return { content: [{ type: "text", text: stringifyJson(data2, 2) ?? "" }] };
3205
3222
  }
3206
3223
  const clientOpts = {
3207
3224
  apiKey: config.apiKey,
@@ -3213,7 +3230,7 @@ function createCallApiFromConfig() {
3213
3230
  params: opts?.params
3214
3231
  });
3215
3232
  const content = [
3216
- { type: "text", text: JSON.stringify(data, null, 2) }
3233
+ { type: "text", text: stringifyJson(data, 2) ?? "" }
3217
3234
  ];
3218
3235
  const warning = formatUnregisteredFactWarning(meta);
3219
3236
  if (warning) content.push({ type: "text", text: warning });
@@ -3577,7 +3594,7 @@ function registerRuleTools(server, callApi) {
3577
3594
  subsequent actions and subsequent winning rules still run. Enforcement is the caller's
3578
3595
  responsibility; the decision surfaces as the is_blocked fact.
3579
3596
 
3580
- RoundingOption (optional, MUTATE_FACT only): { scale: integer (0..16), mode?: "HALF_UP"|"HALF_DOWN"|"HALF_EVEN"|"FLOOR"|"CEILING"|"DOWN"|"UP" } mode defaults to HALF_UP. When omitted, calculator output is preserved at full precision (lossless).
3597
+ RoundingOption (optional, MUTATE_FACT only): { scale: integer (0..34), mode?: "HALF_UP"|"HALF_DOWN"|"HALF_EVEN"|"FLOOR"|"CEILING"|"DOWN"|"UP" } mode defaults to HALF_UP. When omitted, calculator output is preserved at full precision (lossless).
3581
3598
  `,
3582
3599
  inputSchema: {
3583
3600
  groupId: z3.string().uuid().describe("Policy group ID"),
@@ -3588,7 +3605,7 @@ function registerRuleTools(server, callApi) {
3588
3605
  }
3589
3606
  },
3590
3607
  async ({ groupId, versionId, rule }) => {
3591
- const body = JSON.parse(rule);
3608
+ const body = parseJson(rule);
3592
3609
  return callApi("POST", `policy-groups/${groupId}/versions/${versionId}/rules`, { body });
3593
3610
  }
3594
3611
  );
@@ -3607,7 +3624,7 @@ function registerRuleTools(server, callApi) {
3607
3624
  }
3608
3625
  },
3609
3626
  async ({ groupId, versionId, ruleId, rule }) => {
3610
- const body = JSON.parse(rule);
3627
+ const body = parseJson(rule);
3611
3628
  return callApi("PUT", `policy-groups/${groupId}/versions/${versionId}/rules/${ruleId}`, {
3612
3629
  body
3613
3630
  });
@@ -3953,7 +3970,7 @@ function registerAnalyticsTools(server, callApi) {
3953
3970
  }
3954
3971
  },
3955
3972
  async ({ versionId, facts, includeDebugInfo }) => {
3956
- const parsedFacts = JSON.parse(facts);
3973
+ const parsedFacts = parseJson(facts);
3957
3974
  return callApi("POST", `analytics/dry-run/versions/${versionId}`, {
3958
3975
  body: { facts: parsedFacts, includeDebugInfo }
3959
3976
  });
@@ -3977,7 +3994,7 @@ function registerAnalyticsTools(server, callApi) {
3977
3994
  }
3978
3995
  },
3979
3996
  async ({ versionIdA, versionIdB, facts }) => {
3980
- const parsedFacts = JSON.parse(facts);
3997
+ const parsedFacts = parseJson(facts);
3981
3998
  return callApi("POST", "analytics/dry-run/compare", {
3982
3999
  body: { versionIdA, versionIdB, facts: parsedFacts }
3983
4000
  });
@@ -4032,7 +4049,7 @@ function registerAnalyticsTools(server, callApi) {
4032
4049
  }
4033
4050
  },
4034
4051
  async ({ body }) => {
4035
- const parsed = JSON.parse(body);
4052
+ const parsed = parseJson(body);
4036
4053
  return callApi("POST", "analytics/simulations", { body: parsed });
4037
4054
  }
4038
4055
  );
@@ -4122,12 +4139,12 @@ function registerAnalyticsTools(server, callApi) {
4122
4139
  });
4123
4140
  if (!result.isError) {
4124
4141
  try {
4125
- const uploaded = JSON.parse(result.content[0]?.text ?? "{}");
4142
+ const uploaded = parseJson(result.content[0]?.text ?? "{}");
4126
4143
  if (uploaded.path) {
4127
4144
  const dataset = { type: "UPLOADED", source: "S3_BUCKET", path: uploaded.path };
4128
4145
  result.content.push({
4129
4146
  type: "text",
4130
- text: "Ready-to-use dataset block for lexq_simulation_start:\n" + JSON.stringify({ dataset }, null, 2)
4147
+ text: "Ready-to-use dataset block for lexq_simulation_start:\n" + stringifyJson({ dataset }, 2)
4131
4148
  });
4132
4149
  }
4133
4150
  } catch {
@@ -1,4 +1,6 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { LosslessNumber } from 'lossless-json';
3
+ export { LosslessNumber } from 'lossless-json';
2
4
 
3
5
  interface McpToolResult {
4
6
  [key: string]: unknown;
@@ -41,6 +43,19 @@ declare class ApiError extends Error {
41
43
  constructor(statusCode: number, errorCode: string | null, message: string);
42
44
  }
43
45
 
46
+ /** Parses JSON text without rounding numbers that a double cannot hold. */
47
+ declare const parseJson: (text: string) => unknown;
48
+ /**
49
+ * Serializes to JSON, writing preserved numbers back as plain number literals.
50
+ *
51
+ * Use this rather than `JSON.stringify` anywhere a value may have come from {@link parseJson}.
52
+ * The native one turns a preserved number into an object, which breaks both the wire format and
53
+ * `lexq ... --json | jq`.
54
+ */
55
+ declare const stringifyJson: (value: unknown, space?: number | string) => string | undefined;
56
+ /** Type guard for a preserved number. Check this before any generic object branch. */
57
+ declare const isLosslessNumber: (value: unknown) => value is LosslessNumber;
58
+
44
59
  /**
45
60
  * Registers all MCP tools on the given server.
46
61
  *
@@ -49,4 +64,4 @@ declare class ApiError extends Error {
49
64
  */
50
65
  declare function registerAllTools(server: McpServer, callApi: CallApi): void;
51
66
 
52
- export { ApiError, type CallApi, type McpToolResult, formatUnregisteredFactWarning, paginationParams, registerAllTools };
67
+ export { ApiError, type CallApi, type McpToolResult, formatUnregisteredFactWarning, isLosslessNumber, paginationParams, parseJson, registerAllTools, stringifyJson };
@@ -172,6 +172,19 @@ import { join } from "path";
172
172
  var CONFIG_DIR = join(homedir(), ".lexq");
173
173
  var CONFIG_FILE = join(CONFIG_DIR, "config.json");
174
174
 
175
+ // src/lib/lossless-json.ts
176
+ import {
177
+ LosslessNumber,
178
+ isSafeNumber,
179
+ parse as losslessParse,
180
+ stringify as losslessStringify
181
+ } from "lossless-json";
182
+ var survivesDoubleRoundTrip = (literal) => isSafeNumber(literal);
183
+ var parseNumber = (value) => survivesDoubleRoundTrip(value) ? parseFloat(value) : new LosslessNumber(value);
184
+ var parseJson = (text) => losslessParse(text, void 0, parseNumber);
185
+ var stringifyJson = (value, space) => losslessStringify(value, void 0, space);
186
+ var isLosslessNumber = (value) => value instanceof LosslessNumber;
187
+
175
188
  // src/lib/api-client.ts
176
189
  var ApiError = class extends Error {
177
190
  constructor(statusCode, errorCode, message) {
@@ -388,7 +401,7 @@ function registerRuleTools(server, callApi) {
388
401
  subsequent actions and subsequent winning rules still run. Enforcement is the caller's
389
402
  responsibility; the decision surfaces as the is_blocked fact.
390
403
 
391
- RoundingOption (optional, MUTATE_FACT only): { scale: integer (0..16), mode?: "HALF_UP"|"HALF_DOWN"|"HALF_EVEN"|"FLOOR"|"CEILING"|"DOWN"|"UP" } mode defaults to HALF_UP. When omitted, calculator output is preserved at full precision (lossless).
404
+ RoundingOption (optional, MUTATE_FACT only): { scale: integer (0..34), mode?: "HALF_UP"|"HALF_DOWN"|"HALF_EVEN"|"FLOOR"|"CEILING"|"DOWN"|"UP" } mode defaults to HALF_UP. When omitted, calculator output is preserved at full precision (lossless).
392
405
  `,
393
406
  inputSchema: {
394
407
  groupId: z3.string().uuid().describe("Policy group ID"),
@@ -399,7 +412,7 @@ function registerRuleTools(server, callApi) {
399
412
  }
400
413
  },
401
414
  async ({ groupId, versionId, rule }) => {
402
- const body = JSON.parse(rule);
415
+ const body = parseJson(rule);
403
416
  return callApi("POST", `policy-groups/${groupId}/versions/${versionId}/rules`, { body });
404
417
  }
405
418
  );
@@ -418,7 +431,7 @@ function registerRuleTools(server, callApi) {
418
431
  }
419
432
  },
420
433
  async ({ groupId, versionId, ruleId, rule }) => {
421
- const body = JSON.parse(rule);
434
+ const body = parseJson(rule);
422
435
  return callApi("PUT", `policy-groups/${groupId}/versions/${versionId}/rules/${ruleId}`, {
423
436
  body
424
437
  });
@@ -764,7 +777,7 @@ function registerAnalyticsTools(server, callApi) {
764
777
  }
765
778
  },
766
779
  async ({ versionId, facts, includeDebugInfo }) => {
767
- const parsedFacts = JSON.parse(facts);
780
+ const parsedFacts = parseJson(facts);
768
781
  return callApi("POST", `analytics/dry-run/versions/${versionId}`, {
769
782
  body: { facts: parsedFacts, includeDebugInfo }
770
783
  });
@@ -788,7 +801,7 @@ function registerAnalyticsTools(server, callApi) {
788
801
  }
789
802
  },
790
803
  async ({ versionIdA, versionIdB, facts }) => {
791
- const parsedFacts = JSON.parse(facts);
804
+ const parsedFacts = parseJson(facts);
792
805
  return callApi("POST", "analytics/dry-run/compare", {
793
806
  body: { versionIdA, versionIdB, facts: parsedFacts }
794
807
  });
@@ -843,7 +856,7 @@ function registerAnalyticsTools(server, callApi) {
843
856
  }
844
857
  },
845
858
  async ({ body }) => {
846
- const parsed = JSON.parse(body);
859
+ const parsed = parseJson(body);
847
860
  return callApi("POST", "analytics/simulations", { body: parsed });
848
861
  }
849
862
  );
@@ -933,12 +946,12 @@ function registerAnalyticsTools(server, callApi) {
933
946
  });
934
947
  if (!result.isError) {
935
948
  try {
936
- const uploaded = JSON.parse(result.content[0]?.text ?? "{}");
949
+ const uploaded = parseJson(result.content[0]?.text ?? "{}");
937
950
  if (uploaded.path) {
938
951
  const dataset = { type: "UPLOADED", source: "S3_BUCKET", path: uploaded.path };
939
952
  result.content.push({
940
953
  type: "text",
941
- text: "Ready-to-use dataset block for lexq_simulation_start:\n" + JSON.stringify({ dataset }, null, 2)
954
+ text: "Ready-to-use dataset block for lexq_simulation_start:\n" + stringifyJson({ dataset }, 2)
942
955
  });
943
956
  }
944
957
  } catch {
@@ -1383,7 +1396,11 @@ function registerAllTools(server, callApi) {
1383
1396
  }
1384
1397
  export {
1385
1398
  ApiError,
1399
+ LosslessNumber,
1386
1400
  formatUnregisteredFactWarning,
1401
+ isLosslessNumber,
1387
1402
  paginationParams,
1388
- registerAllTools
1403
+ parseJson,
1404
+ registerAllTools,
1405
+ stringifyJson
1389
1406
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexq/cli",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "description": "LexQ CLI — manage policies, simulate rules, and deploy from the terminal. Built for humans and AI agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,6 +30,7 @@
30
30
  "enums": "node scripts/gen-enums.mjs",
31
31
  "enums:check": "node scripts/gen-enums.mjs --check",
32
32
  "start": "node dist/index.js",
33
+ "test:decimals": "pnpm build && node tests/exact-decimals.mjs",
33
34
  "prepublishOnly": "pnpm build",
34
35
  "knip": "knip",
35
36
  "format": "prettier --write \"src/**/*.ts\"",
@@ -61,6 +62,7 @@
61
62
  "cli-table3": "^0.6.5",
62
63
  "commander": "^13.1.0",
63
64
  "dedent": "^1.7.2",
65
+ "lossless-json": "4.3.1",
64
66
  "zod": "^3.25.76"
65
67
  },
66
68
  "devDependencies": {
@@ -193,7 +193,7 @@ Each rule can have multiple actions. Actions fire sequentially.
193
193
  | `method` | always | `PERCENTAGE` \| `AMOUNT` — dictates the unit of `operand` |
194
194
  | `operand` | always | The arithmetic operand. Percent when PERCENTAGE, absolute amount when AMOUNT. |
195
195
  | `refVar` | optional | Base for percentage calculation. Omit to use `targetVar` itself. |
196
- | `rounding` | optional | `{ scale: 0..16, mode?: HALF_UP \| ... }`. Omit for lossless full precision. |
196
+ | `rounding` | optional | `{ scale: 0..34, mode?: HALF_UP \| ... }`. Omit for lossless full precision. |
197
197
 
198
198
  **operator × method matrix**
199
199