@outcomeeng/spx 0.1.8 → 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
@@ -1776,6 +1776,10 @@ var sessionDomain = {
1776
1776
  }
1777
1777
  };
1778
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
+
1779
1783
  // src/scanner/scanner.ts
1780
1784
  import path5 from "path";
1781
1785
  var Scanner = class {
@@ -2333,6 +2337,226 @@ This command is for legacy specs/ projects. For CODE framework projects, check t
2333
2337
  }
2334
2338
  }
2335
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
+
2336
2560
  // src/domains/spec/index.ts
2337
2561
  function registerSpecCommands(specCmd) {
2338
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) => {
@@ -2373,6 +2597,25 @@ function registerSpecCommands(specCmd) {
2373
2597
  process.exit(1);
2374
2598
  }
2375
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
+ });
2376
2619
  }
2377
2620
  var specDomain = {
2378
2621
  name: "spec",
@@ -3244,7 +3487,7 @@ var ParseErrorCode;
3244
3487
 
3245
3488
  // src/validation/config/scope.ts
3246
3489
  import { existsSync, readdirSync, readFileSync } from "fs";
3247
- import { join as join8 } from "path";
3490
+ import { join as join10 } from "path";
3248
3491
  var TSCONFIG_FILES = {
3249
3492
  full: "tsconfig.json",
3250
3493
  production: "tsconfig.production.json"
@@ -3291,7 +3534,7 @@ function hasTypeScriptFilesRecursive(dirPath, maxDepth = 2, deps = defaultScopeD
3291
3534
  if (hasDirectTsFiles) return true;
3292
3535
  const subdirs = items.filter((item) => item.isDirectory() && !item.name.startsWith("."));
3293
3536
  for (const subdir of subdirs.slice(0, 5)) {
3294
- if (hasTypeScriptFilesRecursive(join8(dirPath, subdir.name), maxDepth - 1, deps)) {
3537
+ if (hasTypeScriptFilesRecursive(join10(dirPath, subdir.name), maxDepth - 1, deps)) {
3295
3538
  return true;
3296
3539
  }
3297
3540
  }
@@ -3823,7 +4066,7 @@ import { spawn as spawn3 } from "child_process";
3823
4066
  import { existsSync as existsSync2, mkdirSync, rmSync, writeFileSync } from "fs";
3824
4067
  import { mkdtemp } from "fs/promises";
3825
4068
  import { tmpdir } from "os";
3826
- import { isAbsolute as isAbsolute2, join as join9 } from "path";
4069
+ import { isAbsolute as isAbsolute2, join as join11 } from "path";
3827
4070
  var defaultTypeScriptProcessRunner = { spawn: spawn3 };
3828
4071
  var defaultTypeScriptDeps = {
3829
4072
  mkdtemp,
@@ -3837,19 +4080,19 @@ function buildTypeScriptArgs(context) {
3837
4080
  return scope === VALIDATION_SCOPES.FULL ? ["tsc", "--noEmit"] : ["tsc", "--project", configFile];
3838
4081
  }
3839
4082
  async function createFileSpecificTsconfig(scope, files, deps = defaultTypeScriptDeps) {
3840
- const tempDir = await deps.mkdtemp(join9(tmpdir(), "validate-ts-"));
3841
- const configPath = join9(tempDir, "tsconfig.json");
4083
+ const tempDir = await deps.mkdtemp(join11(tmpdir(), "validate-ts-"));
4084
+ const configPath = join11(tempDir, "tsconfig.json");
3842
4085
  const baseConfigFile = TSCONFIG_FILES[scope];
3843
4086
  const projectRoot = process.cwd();
3844
- const absoluteFiles = files.map((file) => isAbsolute2(file) ? file : join9(projectRoot, file));
4087
+ const absoluteFiles = files.map((file) => isAbsolute2(file) ? file : join11(projectRoot, file));
3845
4088
  const tempConfig = {
3846
- extends: join9(projectRoot, baseConfigFile),
4089
+ extends: join11(projectRoot, baseConfigFile),
3847
4090
  files: absoluteFiles,
3848
4091
  include: [],
3849
4092
  exclude: [],
3850
4093
  compilerOptions: {
3851
4094
  noEmit: true,
3852
- typeRoots: [join9(projectRoot, "node_modules", "@types")],
4095
+ typeRoots: [join11(projectRoot, "node_modules", "@types")],
3853
4096
  types: ["node"]
3854
4097
  }
3855
4098
  };