@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 +251 -8
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
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
|
|
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(
|
|
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
|
|
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(
|
|
3841
|
-
const configPath =
|
|
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 :
|
|
4087
|
+
const absoluteFiles = files.map((file) => isAbsolute2(file) ? file : join11(projectRoot, file));
|
|
3845
4088
|
const tempConfig = {
|
|
3846
|
-
extends:
|
|
4089
|
+
extends: join11(projectRoot, baseConfigFile),
|
|
3847
4090
|
files: absoluteFiles,
|
|
3848
4091
|
include: [],
|
|
3849
4092
|
exclude: [],
|
|
3850
4093
|
compilerOptions: {
|
|
3851
4094
|
noEmit: true,
|
|
3852
|
-
typeRoots: [
|
|
4095
|
+
typeRoots: [join11(projectRoot, "node_modules", "@types")],
|
|
3853
4096
|
types: ["node"]
|
|
3854
4097
|
}
|
|
3855
4098
|
};
|