@outcomeeng/spx 0.1.7 → 0.2.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/dist/cli.js CHANGED
@@ -817,6 +817,42 @@ async function resolveSessionConfig(options = {}) {
817
817
  };
818
818
  }
819
819
 
820
+ // src/session/batch.ts
821
+ var BatchError = class extends Error {
822
+ results;
823
+ constructor(results) {
824
+ const failures = results.filter((r) => !r.ok);
825
+ const successes = results.filter((r) => r.ok);
826
+ super(
827
+ `${failures.length} of ${results.length} operations failed. ${successes.length} succeeded.`
828
+ );
829
+ this.name = "BatchError";
830
+ this.results = results;
831
+ }
832
+ };
833
+ async function processBatch(ids, handler) {
834
+ const results = [];
835
+ for (const id of ids) {
836
+ try {
837
+ const output2 = await handler(id);
838
+ results.push({ id, ok: true, message: output2 });
839
+ } catch (error) {
840
+ const message = error instanceof Error ? error.message : String(error);
841
+ results.push({ id, ok: false, message });
842
+ }
843
+ }
844
+ const output = results.map((r) => r.ok ? r.message : `Error (${r.id}): ${r.message}`).join("\n\n");
845
+ const hasFailures = results.some((r) => !r.ok);
846
+ if (hasFailures) {
847
+ const err = new BatchError(results);
848
+ err.message = `${err.message}
849
+
850
+ ${output}`;
851
+ throw err;
852
+ }
853
+ return output;
854
+ }
855
+
820
856
  // src/session/errors.ts
821
857
  var SessionError = class extends Error {
822
858
  constructor(message) {
@@ -908,14 +944,17 @@ async function resolveArchivePaths(sessionId, config) {
908
944
  }
909
945
  throw new SessionNotFoundError(sessionId);
910
946
  }
911
- async function archiveCommand(options) {
912
- const { config } = await resolveSessionConfig({ sessionsDir: options.sessionsDir });
913
- const { source, target } = await resolveArchivePaths(options.sessionId, config);
947
+ async function archiveSingle(sessionId, config) {
948
+ const { source, target } = await resolveArchivePaths(sessionId, config);
914
949
  await mkdir(dirname2(target), { recursive: true });
915
950
  await rename(source, target);
916
- return `Archived session: ${options.sessionId}
951
+ return `Archived session: ${sessionId}
917
952
  Archive location: ${target}`;
918
953
  }
954
+ async function archiveCommand(options) {
955
+ const { config } = await resolveSessionConfig({ sessionsDir: options.sessionsDir });
956
+ return processBatch(options.sessionIds, (id) => archiveSingle(id, config));
957
+ }
919
958
 
920
959
  // src/commands/session/delete.ts
921
960
  import { stat as stat2, unlink } from "fs/promises";
@@ -1108,13 +1147,16 @@ async function findExistingPaths(paths) {
1108
1147
  }
1109
1148
  return existing;
1110
1149
  }
1111
- async function deleteCommand(options) {
1112
- const { config } = await resolveSessionConfig({ sessionsDir: options.sessionsDir });
1113
- const paths = resolveSessionPaths(options.sessionId, config);
1150
+ async function deleteSingle(sessionId, config) {
1151
+ const paths = resolveSessionPaths(sessionId, config);
1114
1152
  const existingPaths = await findExistingPaths(paths);
1115
- const pathToDelete = resolveDeletePath(options.sessionId, existingPaths);
1153
+ const pathToDelete = resolveDeletePath(sessionId, existingPaths);
1116
1154
  await unlink(pathToDelete);
1117
- return `Deleted session: ${options.sessionId}`;
1155
+ return `Deleted session: ${sessionId}`;
1156
+ }
1157
+ async function deleteCommand(options) {
1158
+ const { config } = await resolveSessionConfig({ sessionsDir: options.sessionsDir });
1159
+ return processBatch(options.sessionIds, (id) => deleteSingle(id, config));
1118
1160
  }
1119
1161
 
1120
1162
  // src/commands/session/handoff.ts
@@ -1511,16 +1553,19 @@ async function findExistingPath(paths, _config) {
1511
1553
  }
1512
1554
  return null;
1513
1555
  }
1514
- async function showCommand(options) {
1515
- const { config } = await resolveSessionConfig({ sessionsDir: options.sessionsDir });
1516
- const paths = resolveSessionPaths(options.sessionId, config);
1556
+ async function showSingle(sessionId, config) {
1557
+ const paths = resolveSessionPaths(sessionId, config);
1517
1558
  const found = await findExistingPath(paths, config);
1518
1559
  if (!found) {
1519
- throw new SessionNotFoundError(options.sessionId);
1560
+ throw new SessionNotFoundError(sessionId);
1520
1561
  }
1521
1562
  const content = await readFile4(found.path, "utf-8");
1522
1563
  return formatShowOutput(content, { status: found.status });
1523
1564
  }
1565
+ async function showCommand(options) {
1566
+ const { config } = await resolveSessionConfig({ sessionsDir: options.sessionsDir });
1567
+ return processBatch(options.sessionIds, (id) => showSingle(id, config));
1568
+ }
1524
1569
 
1525
1570
  // src/domains/session/help.ts
1526
1571
  var SESSION_FORMAT_HELP = `
@@ -1539,7 +1584,8 @@ Workflow:
1539
1584
  1. handoff - Create session (todo)
1540
1585
  2. pickup - Claim session (todo -> doing)
1541
1586
  3. release - Return session (doing -> todo)
1542
- 4. delete - Remove session
1587
+ 4. archive - Move session to archive
1588
+ 5. delete - Remove session permanently
1543
1589
  `;
1544
1590
  var HANDOFF_FRONTMATTER_HELP = `
1545
1591
  Usage:
@@ -1627,10 +1673,10 @@ function registerSessionCommands(sessionCmd) {
1627
1673
  handleError(error);
1628
1674
  }
1629
1675
  });
1630
- sessionCmd.command("show <id>").description("Show session content").option("--sessions-dir <path>", "Custom sessions directory").action(async (id, options) => {
1676
+ sessionCmd.command("show <id...>").description("Show session content").option("--sessions-dir <path>", "Custom sessions directory").action(async (ids, options) => {
1631
1677
  try {
1632
1678
  const output = await showCommand({
1633
- sessionId: id,
1679
+ sessionIds: ids,
1634
1680
  sessionsDir: options.sessionsDir
1635
1681
  });
1636
1682
  console.log(output);
@@ -1677,10 +1723,10 @@ function registerSessionCommands(sessionCmd) {
1677
1723
  handleError(error);
1678
1724
  }
1679
1725
  });
1680
- sessionCmd.command("delete <id>").description("Delete a session").option("--sessions-dir <path>", "Custom sessions directory").action(async (id, options) => {
1726
+ sessionCmd.command("delete <id...>").description("Delete one or more sessions").option("--sessions-dir <path>", "Custom sessions directory").action(async (ids, options) => {
1681
1727
  try {
1682
1728
  const output = await deleteCommand({
1683
- sessionId: id,
1729
+ sessionIds: ids,
1684
1730
  sessionsDir: options.sessionsDir
1685
1731
  });
1686
1732
  console.log(output);
@@ -1705,10 +1751,10 @@ function registerSessionCommands(sessionCmd) {
1705
1751
  handleError(error);
1706
1752
  }
1707
1753
  });
1708
- sessionCmd.command("archive <id>").description("Move a session to the archive directory").option("--sessions-dir <path>", "Custom sessions directory").action(async (id, options) => {
1754
+ sessionCmd.command("archive <id...>").description("Move one or more sessions to the archive directory").option("--sessions-dir <path>", "Custom sessions directory").action(async (ids, options) => {
1709
1755
  try {
1710
1756
  const output = await archiveCommand({
1711
- sessionId: id,
1757
+ sessionIds: ids,
1712
1758
  sessionsDir: options.sessionsDir
1713
1759
  });
1714
1760
  console.log(output);
@@ -1730,6 +1776,10 @@ var sessionDomain = {
1730
1776
  }
1731
1777
  };
1732
1778
 
1779
+ // src/domains/spec/index.ts
1780
+ import { access as access2 } from "fs/promises";
1781
+ import { readFile as readFile5, writeFile as writeFile2 } from "fs/promises";
1782
+
1733
1783
  // src/scanner/scanner.ts
1734
1784
  import path5 from "path";
1735
1785
  var Scanner = class {
@@ -2287,6 +2337,226 @@ This command is for legacy specs/ projects. For CODE framework projects, check t
2287
2337
  }
2288
2338
  }
2289
2339
 
2340
+ // src/spec/apply/exclude/adapters/detect.ts
2341
+ import { join as join8 } from "path";
2342
+
2343
+ // src/spec/apply/exclude/constants.ts
2344
+ var SPX_PREFIX = "spx/";
2345
+ var NODE_SUFFIXES = [".outcome/", ".enabler/", ".capability/", ".feature/", ".story/"];
2346
+ var COMMENT_CHAR = "#";
2347
+ var EXCLUDE_FILENAME = "EXCLUDE";
2348
+
2349
+ // src/spec/apply/exclude/mappings.ts
2350
+ function toPytestIgnore(node) {
2351
+ return `--ignore=${SPX_PREFIX}${node}/`;
2352
+ }
2353
+ function escapeRegex(str) {
2354
+ return str.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
2355
+ }
2356
+ function toMypyRegex(node) {
2357
+ const escaped = escapeRegex(`${SPX_PREFIX}${node}/`);
2358
+ return `^${escaped}`;
2359
+ }
2360
+ function toPyrightPath(node) {
2361
+ return `${SPX_PREFIX}${node}/`;
2362
+ }
2363
+ function isExcludedEntry(val) {
2364
+ const hasPrefix = val.includes(SPX_PREFIX);
2365
+ const hasSuffix = NODE_SUFFIXES.some((suffix) => val.includes(suffix));
2366
+ return hasPrefix && hasSuffix;
2367
+ }
2368
+
2369
+ // src/spec/apply/exclude/adapters/python.ts
2370
+ var PYTHON_CONFIG_FILE = "pyproject.toml";
2371
+ var PYTEST_SECTION = "tool.pytest.ini_options";
2372
+ var MYPY_SECTION = "tool.mypy";
2373
+ var PYRIGHT_SECTION = "tool.pyright";
2374
+ var ARRAY_INDENT = " ";
2375
+ function updatePytestAddopts(content, nodes) {
2376
+ const sectionPattern = new RegExp(`^\\[${PYTEST_SECTION.replace(/\./g, "\\.")}\\]`, "m");
2377
+ const sectionMatch = sectionPattern.exec(content);
2378
+ if (!sectionMatch) return content;
2379
+ const afterSection = content.slice(sectionMatch.index);
2380
+ const addoptsPattern = /^([ \t]*addopts\s*=\s*")((?:[^"\\]|\\.)*)(")/m;
2381
+ const addoptsMatch = addoptsPattern.exec(afterSection);
2382
+ if (!addoptsMatch) return content;
2383
+ const prefix = addoptsMatch[1];
2384
+ const currentValue = addoptsMatch[2];
2385
+ const suffix = addoptsMatch[3];
2386
+ const parts = currentValue.split(/\s+/).filter((p) => p.length > 0);
2387
+ const kept = parts.filter((p) => !isExcludedEntry(p));
2388
+ const newIgnores = nodes.map(toPytestIgnore);
2389
+ const updatedValue = [...kept, ...newIgnores].join(" ");
2390
+ const absoluteStart = sectionMatch.index + addoptsMatch.index;
2391
+ const absoluteEnd = absoluteStart + addoptsMatch[0].length;
2392
+ return content.slice(0, absoluteStart) + prefix + updatedValue + suffix + content.slice(absoluteEnd);
2393
+ }
2394
+ function findTomlArray(content, sectionHeader, key) {
2395
+ const sectionPattern = new RegExp(`^\\[${sectionHeader.replace(/\./g, "\\.")}\\]`, "m");
2396
+ const sectionMatch = sectionPattern.exec(content);
2397
+ if (!sectionMatch) return null;
2398
+ const afterSection = content.slice(sectionMatch.index);
2399
+ const nextSectionMatch = /^\[(?!.*\].*=)/m.exec(afterSection.slice(sectionMatch[0].length));
2400
+ const sectionEnd = nextSectionMatch ? sectionMatch.index + sectionMatch[0].length + nextSectionMatch.index : content.length;
2401
+ const keyPattern = new RegExp(`^([ \\t]*${key}\\s*=\\s*)\\[`, "m");
2402
+ const regionToSearch = content.slice(sectionMatch.index, sectionEnd);
2403
+ const keyMatch = keyPattern.exec(regionToSearch);
2404
+ if (!keyMatch) return null;
2405
+ const arrayStart = sectionMatch.index + keyMatch.index + keyMatch[1].length;
2406
+ let depth = 0;
2407
+ let arrayEnd = arrayStart;
2408
+ for (let i = arrayStart; i < content.length; i++) {
2409
+ if (content[i] === "[") depth++;
2410
+ if (content[i] === "]") {
2411
+ depth--;
2412
+ if (depth === 0) {
2413
+ arrayEnd = i + 1;
2414
+ break;
2415
+ }
2416
+ }
2417
+ }
2418
+ return { arrayStart, arrayEnd };
2419
+ }
2420
+ function updateTomlArray(content, sectionHeader, key, newEntries) {
2421
+ const info = findTomlArray(content, sectionHeader, key);
2422
+ if (!info) return content;
2423
+ const arrayContent = content.slice(info.arrayStart + 1, info.arrayEnd - 1);
2424
+ const lines = arrayContent.split("\n");
2425
+ const keptLines = [];
2426
+ for (const line of lines) {
2427
+ const trimmed = line.trim();
2428
+ if (trimmed === "" || trimmed.startsWith("#")) {
2429
+ keptLines.push(line);
2430
+ continue;
2431
+ }
2432
+ const stringMatch = /"((?:[^"\\]|\\.)*)"/.exec(trimmed);
2433
+ if (stringMatch && isExcludedEntry(stringMatch[1])) {
2434
+ continue;
2435
+ }
2436
+ keptLines.push(line);
2437
+ }
2438
+ const newLines = newEntries.map((entry) => `${ARRAY_INDENT}"${entry}",`);
2439
+ while (keptLines.length > 0 && keptLines[keptLines.length - 1].trim() === "") {
2440
+ keptLines.pop();
2441
+ }
2442
+ const reconstructed = [...keptLines, ...newLines, ""].join("\n");
2443
+ return content.slice(0, info.arrayStart + 1) + reconstructed + content.slice(info.arrayEnd - 1);
2444
+ }
2445
+ var pythonAdapter = {
2446
+ language: "Python",
2447
+ configFile: PYTHON_CONFIG_FILE,
2448
+ tools: ["pytest", "mypy", "pyright"],
2449
+ excluded: ["ruff (style checked regardless of implementation existence)"],
2450
+ applyExclusions(content, nodes) {
2451
+ let result = content;
2452
+ result = updatePytestAddopts(result, nodes);
2453
+ const mypyEntries = nodes.map(toMypyRegex);
2454
+ result = updateTomlArray(result, MYPY_SECTION, "exclude", mypyEntries);
2455
+ const pyrightEntries = nodes.map(toPyrightPath);
2456
+ result = updateTomlArray(result, PYRIGHT_SECTION, "exclude", pyrightEntries);
2457
+ return {
2458
+ changed: result !== content,
2459
+ content: result
2460
+ };
2461
+ }
2462
+ };
2463
+
2464
+ // src/spec/apply/exclude/adapters/detect.ts
2465
+ var ADAPTERS = [pythonAdapter];
2466
+ async function detectLanguage(projectRoot, deps) {
2467
+ for (const adapter of ADAPTERS) {
2468
+ const configPath = join8(projectRoot, adapter.configFile);
2469
+ const exists = await deps.fileExists(configPath);
2470
+ if (exists) {
2471
+ return adapter;
2472
+ }
2473
+ }
2474
+ return null;
2475
+ }
2476
+
2477
+ // src/spec/apply/exclude/help.ts
2478
+ function buildApplyHelp() {
2479
+ const excludePath = `${SPX_PREFIX}${EXCLUDE_FILENAME}`;
2480
+ const languageLines = ADAPTERS.map((adapter) => {
2481
+ const tools = adapter.tools.join(", ");
2482
+ const excluded = adapter.excluded.length > 0 ? `
2483
+ NOT configured: ${adapter.excluded.join(", ")}` : "";
2484
+ return ` ${adapter.language} (${adapter.configFile})
2485
+ Configures: ${tools}${excluded}`;
2486
+ });
2487
+ return `
2488
+ Reads ${excludePath} and applies exclusions to the project's tool configuration.
2489
+ Each excluded node is translated into the appropriate format for the detected
2490
+ language's test runner, type checker, and other quality gate tools.
2491
+
2492
+ Source:
2493
+ ${excludePath}
2494
+ One node path per line. Comments (#) and blank lines are ignored.
2495
+ Path traversal (..) and absolute paths are rejected.
2496
+
2497
+ Supported languages:
2498
+ ${languageLines.join("\n\n")}
2499
+
2500
+ Detection:
2501
+ The language is auto-detected by checking for config files in order.
2502
+ The first match wins.
2503
+
2504
+ Examples:
2505
+ spx spec apply # Apply exclusions to detected config file
2506
+ `;
2507
+ }
2508
+ var APPLY_HELP = buildApplyHelp();
2509
+
2510
+ // src/spec/apply/exclude/exclude-file.ts
2511
+ var TOML_UNSAFE_PATTERN = /["\\\n\r\t]/;
2512
+ var PATH_TRAVERSAL_PATTERN = /(?:^|\/)\.\.(?:\/|$)/;
2513
+ function validateNodePath(path8) {
2514
+ if (path8.startsWith("/")) {
2515
+ return `absolute path rejected: ${path8}`;
2516
+ }
2517
+ if (PATH_TRAVERSAL_PATTERN.test(path8)) {
2518
+ return `path traversal rejected: ${path8}`;
2519
+ }
2520
+ if (TOML_UNSAFE_PATTERN.test(path8)) {
2521
+ return `TOML-unsafe characters rejected: ${path8}`;
2522
+ }
2523
+ return null;
2524
+ }
2525
+ function readExcludedNodes(content) {
2526
+ return content.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith(COMMENT_CHAR)).filter((line) => validateNodePath(line) === null);
2527
+ }
2528
+
2529
+ // src/spec/apply/exclude/command.ts
2530
+ import { join as join9 } from "path";
2531
+ async function applyExcludeCommand(options) {
2532
+ const { cwd, deps } = options;
2533
+ const excludePath = join9(cwd, SPX_PREFIX, EXCLUDE_FILENAME);
2534
+ const excludeExists = await deps.fileExists(excludePath);
2535
+ if (!excludeExists) {
2536
+ return { exitCode: 1, output: `error: ${excludePath} not found` };
2537
+ }
2538
+ const adapter = await detectLanguage(cwd, deps);
2539
+ if (!adapter) {
2540
+ return { exitCode: 1, output: "error: no supported config file found (checked: pyproject.toml)" };
2541
+ }
2542
+ const configPath = join9(cwd, adapter.configFile);
2543
+ const excludeContent = await deps.readFile(excludePath);
2544
+ const configContent = await deps.readFile(configPath);
2545
+ const nodes = readExcludedNodes(excludeContent);
2546
+ if (nodes.length === 0) {
2547
+ return { exitCode: 0, output: "spx/EXCLUDE is empty \u2014 no excluded nodes to apply." };
2548
+ }
2549
+ const result = adapter.applyExclusions(configContent, nodes);
2550
+ if (result.changed) {
2551
+ await deps.writeFile(configPath, result.content);
2552
+ return {
2553
+ exitCode: 0,
2554
+ output: `Updated ${adapter.configFile} from spx/EXCLUDE (${nodes.length} nodes).`
2555
+ };
2556
+ }
2557
+ return { exitCode: 0, output: `${adapter.configFile} is already in sync with spx/EXCLUDE.` };
2558
+ }
2559
+
2290
2560
  // src/domains/spec/index.ts
2291
2561
  function registerSpecCommands(specCmd) {
2292
2562
  specCmd.command("status").description("Get project status").option("--json", "Output as JSON").option("--format <format>", "Output format (text|json|markdown|table)").action(async (options) => {
@@ -2327,6 +2597,25 @@ function registerSpecCommands(specCmd) {
2327
2597
  process.exit(1);
2328
2598
  }
2329
2599
  });
2600
+ specCmd.command("apply").description("Apply spec-tree state to project configuration").addHelpText("after", APPLY_HELP).action(async () => {
2601
+ const result = await applyExcludeCommand({
2602
+ cwd: process.cwd(),
2603
+ deps: {
2604
+ readFile: (path8) => readFile5(path8, "utf-8"),
2605
+ writeFile: (path8, content) => writeFile2(path8, content, "utf-8"),
2606
+ fileExists: async (path8) => {
2607
+ try {
2608
+ await access2(path8);
2609
+ return true;
2610
+ } catch {
2611
+ return false;
2612
+ }
2613
+ }
2614
+ }
2615
+ });
2616
+ if (result.output) console.log(result.output);
2617
+ process.exit(result.exitCode);
2618
+ });
2330
2619
  }
2331
2620
  var specDomain = {
2332
2621
  name: "spec",
@@ -3198,7 +3487,7 @@ var ParseErrorCode;
3198
3487
 
3199
3488
  // src/validation/config/scope.ts
3200
3489
  import { existsSync, readdirSync, readFileSync } from "fs";
3201
- import { join as join8 } from "path";
3490
+ import { join as join10 } from "path";
3202
3491
  var TSCONFIG_FILES = {
3203
3492
  full: "tsconfig.json",
3204
3493
  production: "tsconfig.production.json"
@@ -3245,7 +3534,7 @@ function hasTypeScriptFilesRecursive(dirPath, maxDepth = 2, deps = defaultScopeD
3245
3534
  if (hasDirectTsFiles) return true;
3246
3535
  const subdirs = items.filter((item) => item.isDirectory() && !item.name.startsWith("."));
3247
3536
  for (const subdir of subdirs.slice(0, 5)) {
3248
- if (hasTypeScriptFilesRecursive(join8(dirPath, subdir.name), maxDepth - 1, deps)) {
3537
+ if (hasTypeScriptFilesRecursive(join10(dirPath, subdir.name), maxDepth - 1, deps)) {
3249
3538
  return true;
3250
3539
  }
3251
3540
  }
@@ -3777,7 +4066,7 @@ import { spawn as spawn3 } from "child_process";
3777
4066
  import { existsSync as existsSync2, mkdirSync, rmSync, writeFileSync } from "fs";
3778
4067
  import { mkdtemp } from "fs/promises";
3779
4068
  import { tmpdir } from "os";
3780
- import { isAbsolute as isAbsolute2, join as join9 } from "path";
4069
+ import { isAbsolute as isAbsolute2, join as join11 } from "path";
3781
4070
  var defaultTypeScriptProcessRunner = { spawn: spawn3 };
3782
4071
  var defaultTypeScriptDeps = {
3783
4072
  mkdtemp,
@@ -3791,19 +4080,19 @@ function buildTypeScriptArgs(context) {
3791
4080
  return scope === VALIDATION_SCOPES.FULL ? ["tsc", "--noEmit"] : ["tsc", "--project", configFile];
3792
4081
  }
3793
4082
  async function createFileSpecificTsconfig(scope, files, deps = defaultTypeScriptDeps) {
3794
- const tempDir = await deps.mkdtemp(join9(tmpdir(), "validate-ts-"));
3795
- const configPath = join9(tempDir, "tsconfig.json");
4083
+ const tempDir = await deps.mkdtemp(join11(tmpdir(), "validate-ts-"));
4084
+ const configPath = join11(tempDir, "tsconfig.json");
3796
4085
  const baseConfigFile = TSCONFIG_FILES[scope];
3797
4086
  const projectRoot = process.cwd();
3798
- const absoluteFiles = files.map((file) => isAbsolute2(file) ? file : join9(projectRoot, file));
4087
+ const absoluteFiles = files.map((file) => isAbsolute2(file) ? file : join11(projectRoot, file));
3799
4088
  const tempConfig = {
3800
- extends: join9(projectRoot, baseConfigFile),
4089
+ extends: join11(projectRoot, baseConfigFile),
3801
4090
  files: absoluteFiles,
3802
4091
  include: [],
3803
4092
  exclude: [],
3804
4093
  compilerOptions: {
3805
4094
  noEmit: true,
3806
- typeRoots: [join9(projectRoot, "node_modules", "@types")],
4095
+ typeRoots: [join11(projectRoot, "node_modules", "@types")],
3807
4096
  types: ["node"]
3808
4097
  }
3809
4098
  };