@outcomeeng/spx 0.2.0 → 0.3.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
@@ -7,6 +7,227 @@ import {
7
7
  import { Command } from "commander";
8
8
  import { createRequire as createRequire2 } from "module";
9
9
 
10
+ // src/audit/paths.ts
11
+ import { existsSync } from "fs";
12
+ import { relative, resolve } from "path";
13
+ function validatePaths(verdict, projectRoot) {
14
+ const defects = [];
15
+ const root = resolve(projectRoot);
16
+ for (const gate of verdict.gates) {
17
+ for (const finding of gate.findings) {
18
+ checkPath(finding.spec_file, root, defects);
19
+ checkPath(finding.test_file, root, defects);
20
+ }
21
+ }
22
+ return defects;
23
+ }
24
+ function checkPath(filePath, root, defects) {
25
+ if (!filePath) return;
26
+ const rel = relative(root, resolve(root, filePath));
27
+ if (rel.startsWith("..")) {
28
+ defects.push(`path escapes project root: ${filePath}`);
29
+ return;
30
+ }
31
+ if (!existsSync(resolve(root, filePath))) {
32
+ defects.push(`missing file: ${filePath}`);
33
+ }
34
+ }
35
+
36
+ // src/audit/reader.ts
37
+ import { readFile } from "fs/promises";
38
+ import { XMLParser, XMLValidator } from "fast-xml-parser";
39
+ var ARRAY_TAGS = /* @__PURE__ */ new Set(["gate", "finding"]);
40
+ var PARSER_OPTIONS = {
41
+ ignoreAttributes: false,
42
+ attributeNamePrefix: "@_",
43
+ parseTagValue: false,
44
+ parseAttributeValue: false,
45
+ isArray: (name) => ARRAY_TAGS.has(name)
46
+ };
47
+ async function readVerdictFile(filePath) {
48
+ let xml;
49
+ try {
50
+ xml = await readFile(filePath, "utf-8");
51
+ } catch (cause) {
52
+ throw new Error(`Failed to read verdict file: ${filePath}`, { cause });
53
+ }
54
+ const validation = XMLValidator.validate(xml);
55
+ if (validation !== true) {
56
+ throw new Error(
57
+ `Malformed XML in verdict file: ${filePath}: ${validation.err.msg}`
58
+ );
59
+ }
60
+ const parser = new XMLParser(PARSER_OPTIONS);
61
+ const parsed = parser.parse(xml);
62
+ return buildVerdict(parsed);
63
+ }
64
+ function isObject(value) {
65
+ return typeof value === "object" && value !== null;
66
+ }
67
+ function asString(value) {
68
+ return typeof value === "string" ? value : void 0;
69
+ }
70
+ function buildVerdict(parsed) {
71
+ const rootUnknown = parsed["audit_verdict"];
72
+ if (!isObject(rootUnknown)) {
73
+ return { gates: [] };
74
+ }
75
+ const headerUnknown = rootUnknown["header"];
76
+ const gatesUnknown = rootUnknown["gates"];
77
+ return {
78
+ header: isObject(headerUnknown) ? buildHeader(headerUnknown) : void 0,
79
+ gates: isObject(gatesUnknown) ? buildGates(gatesUnknown) : []
80
+ };
81
+ }
82
+ function buildHeader(raw) {
83
+ return {
84
+ spec_node: asString(raw["spec_node"]),
85
+ verdict: asString(raw["verdict"]),
86
+ timestamp: asString(raw["timestamp"])
87
+ };
88
+ }
89
+ function buildGates(raw) {
90
+ const gatesUnknown = raw["gate"];
91
+ if (!Array.isArray(gatesUnknown)) return [];
92
+ return gatesUnknown.filter(isObject).map(buildGate);
93
+ }
94
+ function buildGate(raw) {
95
+ const findingsUnknown = raw["findings"];
96
+ return {
97
+ name: asString(raw["name"]),
98
+ status: asString(raw["status"]),
99
+ skipped_reason: asString(raw["skipped_reason"]),
100
+ count: isObject(findingsUnknown) ? asString(findingsUnknown["@_count"]) : void 0,
101
+ findings: isObject(findingsUnknown) ? buildFindings(findingsUnknown) : []
102
+ };
103
+ }
104
+ function buildFindings(raw) {
105
+ const findingsUnknown = raw["finding"];
106
+ if (!Array.isArray(findingsUnknown)) return [];
107
+ return findingsUnknown.filter(isObject).map(buildFinding);
108
+ }
109
+ function buildFinding(raw) {
110
+ return {
111
+ spec_file: asString(raw["spec_file"]),
112
+ test_file: asString(raw["test_file"])
113
+ };
114
+ }
115
+
116
+ // src/audit/semantic.ts
117
+ function validateSemantics(verdict) {
118
+ const defects = [];
119
+ const hasFail = verdict.gates.some((g) => g.status === "FAIL");
120
+ const hasSkipped = verdict.gates.some((g) => g.status === "SKIPPED");
121
+ if (verdict.header?.verdict === "APPROVED" && (hasFail || hasSkipped)) {
122
+ defects.push("incoherent verdict: APPROVED but at least one gate is not PASS");
123
+ } else if (verdict.header?.verdict === "REJECT" && !hasFail && !hasSkipped) {
124
+ defects.push("incoherent verdict: REJECT but all gates are PASS");
125
+ }
126
+ for (const gate of verdict.gates) {
127
+ const label = gate.name ? `gate "${gate.name}"` : "gate";
128
+ if (gate.status === "FAIL" && gate.findings.length === 0) {
129
+ defects.push(`failed gate has no findings: ${label}`);
130
+ }
131
+ if (gate.status === "SKIPPED" && !gate.skipped_reason) {
132
+ defects.push(`skipped gate missing reason: ${label}`);
133
+ }
134
+ }
135
+ return defects;
136
+ }
137
+
138
+ // src/audit/structural.ts
139
+ var VALID_VERDICTS = /* @__PURE__ */ new Set(["APPROVED", "REJECT"]);
140
+ var VALID_GATE_STATUSES = /* @__PURE__ */ new Set(["PASS", "FAIL", "SKIPPED"]);
141
+ function validateStructure(verdict) {
142
+ const defects = [];
143
+ if (!verdict.header) {
144
+ defects.push("missing required element: <header>");
145
+ return defects;
146
+ }
147
+ if (!verdict.header.spec_node) {
148
+ defects.push("missing required element: <spec_node>");
149
+ }
150
+ if (!verdict.header.verdict) {
151
+ defects.push("missing required element: <verdict> in <header>");
152
+ } else if (!VALID_VERDICTS.has(verdict.header.verdict)) {
153
+ defects.push(
154
+ `invalid enum value: verdict "${verdict.header.verdict}" is not one of APPROVED, REJECT`
155
+ );
156
+ }
157
+ if (!verdict.header.timestamp) {
158
+ defects.push("missing required element: <timestamp>");
159
+ }
160
+ if (verdict.gates.length === 0) {
161
+ defects.push("missing required element: at least one <gate> in <gates>");
162
+ }
163
+ for (const gate of verdict.gates) {
164
+ const label = gate.name ? `gate "${gate.name}"` : "gate";
165
+ if (gate.status !== void 0 && !VALID_GATE_STATUSES.has(gate.status)) {
166
+ defects.push(
167
+ `invalid enum value: ${label} status "${gate.status}" is not one of PASS, FAIL, SKIPPED`
168
+ );
169
+ }
170
+ if (gate.count !== void 0) {
171
+ const declared = parseInt(gate.count, 10);
172
+ const actual = gate.findings.length;
173
+ if (isNaN(declared) || declared !== actual) {
174
+ defects.push(
175
+ `count mismatch: ${label} declares count="${gate.count}" but has ${actual} finding(s)`
176
+ );
177
+ }
178
+ }
179
+ }
180
+ return defects;
181
+ }
182
+
183
+ // src/audit/verify.ts
184
+ async function runVerifyPipeline(filePath, projectRoot) {
185
+ let verdict;
186
+ try {
187
+ verdict = await readVerdictFile(filePath);
188
+ } catch (error) {
189
+ const message = error instanceof Error ? error.message : String(error);
190
+ return { lines: [`reader: ${message}`], exitCode: 1 };
191
+ }
192
+ const structuralDefects = validateStructure(verdict);
193
+ if (structuralDefects.length > 0) {
194
+ return { lines: structuralDefects.map((d) => `structural: ${d}`), exitCode: 1 };
195
+ }
196
+ const semanticDefects = validateSemantics(verdict);
197
+ if (semanticDefects.length > 0) {
198
+ return { lines: semanticDefects.map((d) => `semantic: ${d}`), exitCode: 1 };
199
+ }
200
+ const pathDefects = validatePaths(verdict, projectRoot);
201
+ if (pathDefects.length > 0) {
202
+ return { lines: pathDefects.map((d) => `paths: ${d}`), exitCode: 1 };
203
+ }
204
+ return { lines: [], exitCode: 0, verdict: verdict.header?.verdict };
205
+ }
206
+
207
+ // src/audit/cli.ts
208
+ async function runVerifyCommand(filePath, projectRoot, writeLine) {
209
+ const result = await runVerifyPipeline(filePath, projectRoot);
210
+ if (result.exitCode === 0) {
211
+ writeLine(result.verdict ?? "");
212
+ } else {
213
+ for (const line of result.lines) {
214
+ writeLine(line);
215
+ }
216
+ }
217
+ return result.exitCode;
218
+ }
219
+ var auditDomain = {
220
+ name: "audit",
221
+ description: "Audit verdict verification",
222
+ register: (program2) => {
223
+ const auditCmd = program2.command("audit").description("Audit verdict verification");
224
+ auditCmd.command("verify <file>").description("Verify an audit verdict XML file").action(async (file) => {
225
+ const exitCode = await runVerifyCommand(file, process.cwd(), console.log);
226
+ process.exit(exitCode);
227
+ });
228
+ }
229
+ };
230
+
10
231
  // src/commands/claude/init.ts
11
232
  import { execa } from "execa";
12
233
  async function initCommand(options = {}) {
@@ -86,9 +307,7 @@ async function findSettingsFiles(root, visited = /* @__PURE__ */ new Set()) {
86
307
  if (error.message.includes("EACCES")) {
87
308
  throw new Error(`Permission denied: ${normalizedRoot}`);
88
309
  }
89
- throw new Error(
90
- `Failed to search directory "${normalizedRoot}": ${error.message}`
91
- );
310
+ throw new Error(`Failed to search directory "${normalizedRoot}": ${error.message}`);
92
311
  }
93
312
  throw error;
94
313
  }
@@ -549,11 +768,11 @@ import fs5 from "fs/promises";
549
768
  import os from "os";
550
769
  import path3 from "path";
551
770
  var realFs = {
552
- writeFile: (path8, content) => fs5.writeFile(path8, content, "utf-8"),
771
+ writeFile: (path9, content) => fs5.writeFile(path9, content, "utf-8"),
553
772
  rename: fs5.rename,
554
773
  unlink: fs5.unlink,
555
- mkdir: async (path8, options) => {
556
- await fs5.mkdir(path8, options);
774
+ mkdir: async (path9, options) => {
775
+ await fs5.mkdir(path9, options);
557
776
  }
558
777
  };
559
778
  async function writeSettings(filePath, settings, deps = { fs: realFs }) {
@@ -702,13 +921,459 @@ var claudeDomain = {
702
921
  }
703
922
  };
704
923
 
924
+ // src/commands/config/defaults.ts
925
+ import { stringify as yamlStringify } from "yaml";
926
+ var JSON_INDENT = 2;
927
+ function defaultsCommand(options, deps) {
928
+ const config = {};
929
+ for (const descriptor of deps.descriptors) {
930
+ config[descriptor.section] = descriptor.defaults;
931
+ }
932
+ return Promise.resolve({
933
+ stdout: formatConfig(config, options.json === true),
934
+ stderr: "",
935
+ exitCode: 0
936
+ });
937
+ }
938
+ function formatConfig(config, asJson) {
939
+ if (asJson) {
940
+ return `${JSON.stringify(config, null, JSON_INDENT)}
941
+ `;
942
+ }
943
+ return yamlStringify(config);
944
+ }
945
+
946
+ // src/commands/config/show.ts
947
+ import { stringify as yamlStringify2 } from "yaml";
948
+ var JSON_INDENT2 = 2;
949
+ var EXIT_CODE_ERROR = 1;
950
+ async function showCommand(options, deps) {
951
+ const projectRoot = deps.resolveProjectRoot();
952
+ const result = await deps.resolveConfig(projectRoot);
953
+ if (!result.ok) {
954
+ return {
955
+ stdout: "",
956
+ stderr: `${result.error}
957
+ `,
958
+ exitCode: EXIT_CODE_ERROR
959
+ };
960
+ }
961
+ return {
962
+ stdout: formatConfig2(result.value, options.json === true),
963
+ stderr: "",
964
+ exitCode: 0
965
+ };
966
+ }
967
+ function formatConfig2(config, asJson) {
968
+ if (asJson) {
969
+ return `${JSON.stringify(config, null, JSON_INDENT2)}
970
+ `;
971
+ }
972
+ return yamlStringify2(config);
973
+ }
974
+
975
+ // src/commands/config/types.ts
976
+ var CONFIG_FILENAME = "spx.config.yaml";
977
+
978
+ // src/commands/config/validate.ts
979
+ var EXIT_CODE_INVALID = 1;
980
+ async function validateCommand(_options, deps) {
981
+ const projectRoot = deps.resolveProjectRoot();
982
+ const result = await deps.resolveConfig(projectRoot);
983
+ if (!result.ok) {
984
+ return {
985
+ stdout: "",
986
+ stderr: `${result.error}
987
+ `,
988
+ exitCode: EXIT_CODE_INVALID
989
+ };
990
+ }
991
+ return {
992
+ stdout: `${CONFIG_FILENAME} at ${projectRoot} passes every registered descriptor's validator.
993
+ `,
994
+ stderr: "",
995
+ exitCode: 0
996
+ };
997
+ }
998
+
999
+ // src/config/index.ts
1000
+ import { readFile as readFile2 } from "fs/promises";
1001
+ import { join } from "path";
1002
+ import { parse as parseToml } from "smol-toml";
1003
+ import { parse as parseYaml } from "yaml";
1004
+
1005
+ // src/spec/config.ts
1006
+ var KIND_REGISTRY = {
1007
+ enabler: { category: "node", suffix: ".enabler" },
1008
+ outcome: { category: "node", suffix: ".outcome" },
1009
+ adr: { category: "decision", suffix: ".adr.md" },
1010
+ pdr: { category: "decision", suffix: ".pdr.md" }
1011
+ };
1012
+ var NODE_KINDS = Object.keys(KIND_REGISTRY).filter(
1013
+ (k) => KIND_REGISTRY[k].category === "node"
1014
+ );
1015
+ var DECISION_KINDS = Object.keys(KIND_REGISTRY).filter(
1016
+ (k) => KIND_REGISTRY[k].category === "decision"
1017
+ );
1018
+ var NODE_SUFFIXES = NODE_KINDS.map((k) => KIND_REGISTRY[k].suffix);
1019
+ var DECISION_SUFFIXES = DECISION_KINDS.map((k) => KIND_REGISTRY[k].suffix);
1020
+ var SPEC_TREE_SECTION = "specTree";
1021
+ function isKind(value) {
1022
+ return Object.prototype.hasOwnProperty.call(KIND_REGISTRY, value);
1023
+ }
1024
+ function buildDefaults() {
1025
+ return { kinds: { ...KIND_REGISTRY } };
1026
+ }
1027
+ function validate(value) {
1028
+ if (typeof value !== "object" || value === null) {
1029
+ return { ok: false, error: `${SPEC_TREE_SECTION} section must be an object` };
1030
+ }
1031
+ const candidate = value;
1032
+ if (typeof candidate.kinds !== "object" || candidate.kinds === null || Array.isArray(candidate.kinds)) {
1033
+ return {
1034
+ ok: false,
1035
+ error: `${SPEC_TREE_SECTION}.kinds must be an object keyed by kind name`
1036
+ };
1037
+ }
1038
+ const entries = [];
1039
+ const kindEntries = candidate.kinds;
1040
+ for (const [key, entry] of Object.entries(kindEntries)) {
1041
+ if (!isKind(key)) {
1042
+ return {
1043
+ ok: false,
1044
+ error: `${SPEC_TREE_SECTION}.kinds contains unknown kind "${key}"`
1045
+ };
1046
+ }
1047
+ if (typeof entry !== "object" || entry === null) {
1048
+ return {
1049
+ ok: false,
1050
+ error: `${SPEC_TREE_SECTION}.kinds.${key} must be an object with category and suffix`
1051
+ };
1052
+ }
1053
+ const def = entry;
1054
+ const expected = KIND_REGISTRY[key];
1055
+ if (def.category !== expected.category) {
1056
+ return {
1057
+ ok: false,
1058
+ error: `${SPEC_TREE_SECTION}.kinds.${key}.category must be "${expected.category}"`
1059
+ };
1060
+ }
1061
+ if (def.suffix !== expected.suffix) {
1062
+ return {
1063
+ ok: false,
1064
+ error: `${SPEC_TREE_SECTION}.kinds.${key}.suffix must be "${expected.suffix}"`
1065
+ };
1066
+ }
1067
+ entries.push([key, expected]);
1068
+ }
1069
+ const kinds = Object.fromEntries(entries);
1070
+ return { ok: true, value: { kinds } };
1071
+ }
1072
+ var specTreeConfigDescriptor = {
1073
+ section: SPEC_TREE_SECTION,
1074
+ defaults: buildDefaults(),
1075
+ validate
1076
+ };
1077
+
1078
+ // src/validation/literal/config.ts
1079
+ var LITERAL_SECTION = "literal";
1080
+ var DEFAULT_MIN_STRING_LENGTH = 4;
1081
+ var DEFAULT_MIN_NUMBER_DIGITS = 4;
1082
+ var WEB_PRESET = /* @__PURE__ */ new Set([
1083
+ "GET",
1084
+ "POST",
1085
+ "PUT",
1086
+ "DELETE",
1087
+ "PATCH",
1088
+ "Content-Type",
1089
+ "Authorization",
1090
+ "Accept",
1091
+ "status",
1092
+ "message",
1093
+ "error",
1094
+ "data",
1095
+ "class",
1096
+ "id",
1097
+ "href",
1098
+ "src",
1099
+ "type",
1100
+ "name",
1101
+ "value"
1102
+ ]);
1103
+ var PRESET_REGISTRY = /* @__PURE__ */ new Map([
1104
+ ["web", WEB_PRESET]
1105
+ ]);
1106
+ function resolveAllowlist(config) {
1107
+ const effective = /* @__PURE__ */ new Set();
1108
+ for (const presetId of config.presets ?? []) {
1109
+ const preset = PRESET_REGISTRY.get(presetId);
1110
+ if (preset !== void 0) {
1111
+ for (const v of preset) effective.add(v);
1112
+ }
1113
+ }
1114
+ for (const v of config.include ?? []) {
1115
+ effective.add(v);
1116
+ }
1117
+ for (const v of config.exclude ?? []) {
1118
+ effective.delete(v);
1119
+ }
1120
+ return effective;
1121
+ }
1122
+ var defaults = {
1123
+ allowlist: {},
1124
+ minStringLength: DEFAULT_MIN_STRING_LENGTH,
1125
+ minNumberDigits: DEFAULT_MIN_NUMBER_DIGITS
1126
+ };
1127
+ function validate2(value) {
1128
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1129
+ return { ok: false, error: `${LITERAL_SECTION} section must be an object` };
1130
+ }
1131
+ const candidate = value;
1132
+ const allowlistRaw = candidate["allowlist"] ?? {};
1133
+ const allowlistResult = validateAllowlist(allowlistRaw);
1134
+ if (!allowlistResult.ok) {
1135
+ return allowlistResult;
1136
+ }
1137
+ const minStringLength = candidate["minStringLength"] ?? defaults.minStringLength;
1138
+ if (typeof minStringLength !== "number" || !Number.isInteger(minStringLength) || minStringLength < 0) {
1139
+ return {
1140
+ ok: false,
1141
+ error: `${LITERAL_SECTION}.minStringLength must be a non-negative integer`
1142
+ };
1143
+ }
1144
+ const minNumberDigits = candidate["minNumberDigits"] ?? defaults.minNumberDigits;
1145
+ if (typeof minNumberDigits !== "number" || !Number.isInteger(minNumberDigits) || minNumberDigits < 0) {
1146
+ return {
1147
+ ok: false,
1148
+ error: `${LITERAL_SECTION}.minNumberDigits must be a non-negative integer`
1149
+ };
1150
+ }
1151
+ return { ok: true, value: { allowlist: allowlistResult.value, minStringLength, minNumberDigits } };
1152
+ }
1153
+ function validateAllowlist(raw) {
1154
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
1155
+ return { ok: false, error: `${LITERAL_SECTION}.allowlist must be an object` };
1156
+ }
1157
+ const candidate = raw;
1158
+ const presets = candidate["presets"];
1159
+ if (presets !== void 0) {
1160
+ if (!Array.isArray(presets) || !presets.every((x) => typeof x === "string")) {
1161
+ return { ok: false, error: `${LITERAL_SECTION}.allowlist.presets must be an array of strings` };
1162
+ }
1163
+ for (const id of presets) {
1164
+ if (!PRESET_REGISTRY.has(id)) {
1165
+ return { ok: false, error: `${LITERAL_SECTION}.allowlist.presets: unrecognized preset "${id}"` };
1166
+ }
1167
+ }
1168
+ }
1169
+ const include = candidate["include"];
1170
+ if (include !== void 0 && (!Array.isArray(include) || !include.every((x) => typeof x === "string"))) {
1171
+ return { ok: false, error: `${LITERAL_SECTION}.allowlist.include must be an array of strings` };
1172
+ }
1173
+ const exclude = candidate["exclude"];
1174
+ if (exclude !== void 0 && (!Array.isArray(exclude) || !exclude.every((x) => typeof x === "string"))) {
1175
+ return { ok: false, error: `${LITERAL_SECTION}.allowlist.exclude must be an array of strings` };
1176
+ }
1177
+ return {
1178
+ ok: true,
1179
+ value: {
1180
+ presets,
1181
+ include,
1182
+ exclude
1183
+ }
1184
+ };
1185
+ }
1186
+ var literalConfigDescriptor = {
1187
+ section: LITERAL_SECTION,
1188
+ defaults,
1189
+ validate: validate2
1190
+ };
1191
+
1192
+ // src/config/registry.ts
1193
+ var productionRegistry = [
1194
+ specTreeConfigDescriptor,
1195
+ literalConfigDescriptor
1196
+ ];
1197
+
1198
+ // src/config/index.ts
1199
+ var CONFIG_FILENAMES = {
1200
+ json: "spx.config.json",
1201
+ yaml: "spx.config.yaml",
1202
+ toml: "spx.config.toml"
1203
+ };
1204
+ async function resolveConfig(projectRoot, descriptors = productionRegistry) {
1205
+ const detectedResult = await detectConfigFiles(projectRoot);
1206
+ if (!detectedResult.ok) {
1207
+ return detectedResult;
1208
+ }
1209
+ const detected = detectedResult.value;
1210
+ if (detected.length > 1) {
1211
+ const names = detected.map((f) => f.filename).join(", ");
1212
+ return { ok: false, error: `multiple config files found: ${names}` };
1213
+ }
1214
+ const sectionsResult = detected.length === 0 ? { ok: true, value: {} } : parseSections(detected[0]);
1215
+ if (!sectionsResult.ok) {
1216
+ return sectionsResult;
1217
+ }
1218
+ const sections = sectionsResult.value;
1219
+ const resolved = {};
1220
+ for (const descriptor of descriptors) {
1221
+ const sectionValue = sections[descriptor.section];
1222
+ if (sectionValue === void 0) {
1223
+ resolved[descriptor.section] = descriptor.defaults;
1224
+ continue;
1225
+ }
1226
+ const validated = descriptor.validate(sectionValue);
1227
+ if (!validated.ok) {
1228
+ return { ok: false, error: `${descriptor.section}: ${validated.error}` };
1229
+ }
1230
+ resolved[descriptor.section] = validated.value;
1231
+ }
1232
+ return { ok: true, value: resolved };
1233
+ }
1234
+ async function detectConfigFiles(projectRoot) {
1235
+ const detected = [];
1236
+ for (const filename of Object.values(CONFIG_FILENAMES)) {
1237
+ let raw;
1238
+ try {
1239
+ raw = await readFile2(join(projectRoot, filename), "utf8");
1240
+ } catch (error) {
1241
+ if (isFileNotFound(error)) continue;
1242
+ return { ok: false, error: `failed to read ${filename}: ${toMessage(error)}` };
1243
+ }
1244
+ detected.push({ filename, raw });
1245
+ }
1246
+ return { ok: true, value: detected };
1247
+ }
1248
+ function parseSections(file) {
1249
+ switch (file.filename) {
1250
+ case CONFIG_FILENAMES.json:
1251
+ return parseJsonSections(file.filename, file.raw);
1252
+ case CONFIG_FILENAMES.yaml:
1253
+ return parseYamlSections(file.filename, file.raw);
1254
+ default:
1255
+ return parseTomlSections(file.filename, file.raw);
1256
+ }
1257
+ }
1258
+ function parseJsonSections(filename, raw) {
1259
+ let parsed;
1260
+ try {
1261
+ parsed = JSON.parse(raw);
1262
+ } catch (error) {
1263
+ return { ok: false, error: `${filename} is not valid json: ${toMessage(error)}` };
1264
+ }
1265
+ return validateParsedSections(filename, parsed);
1266
+ }
1267
+ function parseYamlSections(filename, raw) {
1268
+ let parsed;
1269
+ try {
1270
+ parsed = parseYaml(raw);
1271
+ } catch (error) {
1272
+ return { ok: false, error: `${filename} is not valid yaml: ${toMessage(error)}` };
1273
+ }
1274
+ if (parsed === null || parsed === void 0) {
1275
+ return { ok: true, value: {} };
1276
+ }
1277
+ return validateParsedSections(filename, parsed);
1278
+ }
1279
+ function parseTomlSections(filename, raw) {
1280
+ let parsed;
1281
+ try {
1282
+ parsed = parseToml(raw);
1283
+ } catch (error) {
1284
+ return { ok: false, error: `${filename} is not valid toml: ${toMessage(error)}` };
1285
+ }
1286
+ return validateParsedSections(filename, parsed);
1287
+ }
1288
+ function validateParsedSections(filename, parsed) {
1289
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1290
+ return { ok: false, error: `${filename} must parse to a mapping of descriptor sections` };
1291
+ }
1292
+ return { ok: true, value: parsed };
1293
+ }
1294
+ function isFileNotFound(error) {
1295
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
1296
+ }
1297
+ function toMessage(error) {
1298
+ return error instanceof Error ? error.message : String(error);
1299
+ }
1300
+
1301
+ // src/domains/config/root.ts
1302
+ import { execSync } from "child_process";
1303
+ import { resolve as resolve2 } from "path";
1304
+ var GIT_TOPLEVEL_CMD = "git rev-parse --show-toplevel";
1305
+ var GIT_NOT_REPO_MARKER = "not a git repository";
1306
+ function resolveProjectRoot(cwd = process.cwd()) {
1307
+ const resolvedCwd = resolve2(cwd);
1308
+ try {
1309
+ const stdout = execSync(GIT_TOPLEVEL_CMD, {
1310
+ cwd: resolvedCwd,
1311
+ stdio: ["ignore", "pipe", "pipe"],
1312
+ encoding: "utf8"
1313
+ });
1314
+ const toplevel = stdout.trim();
1315
+ if (toplevel.length > 0) {
1316
+ return { projectRoot: resolve2(toplevel) };
1317
+ }
1318
+ } catch {
1319
+ }
1320
+ return {
1321
+ projectRoot: resolvedCwd,
1322
+ warning: `warning: ${resolvedCwd} is not inside a git worktree \u2014 falling back to the current working directory. ${GIT_NOT_REPO_MARKER}.`
1323
+ };
1324
+ }
1325
+
1326
+ // src/domains/config/index.ts
1327
+ function buildDefaultDeps() {
1328
+ return {
1329
+ resolveConfig,
1330
+ resolveProjectRoot: () => {
1331
+ const resolved = resolveProjectRoot();
1332
+ if (resolved.warning !== void 0) {
1333
+ process.stderr.write(`${resolved.warning}
1334
+ `);
1335
+ }
1336
+ return resolved.projectRoot;
1337
+ },
1338
+ descriptors: productionRegistry
1339
+ };
1340
+ }
1341
+ async function emit(result) {
1342
+ if (result.stdout.length > 0) {
1343
+ process.stdout.write(result.stdout);
1344
+ }
1345
+ if (result.stderr.length > 0) {
1346
+ process.stderr.write(result.stderr);
1347
+ }
1348
+ return process.exit(result.exitCode);
1349
+ }
1350
+ function registerConfigCommands(configCmd) {
1351
+ configCmd.command("show").description("Print the resolved configuration as YAML (or JSON with --json)").option("--json", "Output as JSON").action(async (options) => {
1352
+ await emit(await showCommand(options, buildDefaultDeps()));
1353
+ });
1354
+ configCmd.command("validate").description("Verify that spx.config.yaml passes every registered descriptor's validator").action(async (options) => {
1355
+ await emit(await validateCommand(options, buildDefaultDeps()));
1356
+ });
1357
+ configCmd.command("defaults").description("Print each registered descriptor's defaults \u2014 ignores spx.config.yaml").option("--json", "Output as JSON").action(async (options) => {
1358
+ await emit(await defaultsCommand(options, buildDefaultDeps()));
1359
+ });
1360
+ }
1361
+ var configDomain = {
1362
+ name: "config",
1363
+ description: "Inspect and validate the resolved spx configuration",
1364
+ register: (program2) => {
1365
+ const configCmd = program2.command("config").description("Inspect and validate the resolved spx configuration");
1366
+ registerConfigCommands(configCmd);
1367
+ }
1368
+ };
1369
+
705
1370
  // src/commands/session/archive.ts
706
1371
  import { mkdir, rename, stat } from "fs/promises";
707
- import { dirname as dirname2, join as join2 } from "path";
1372
+ import { dirname as dirname2, join as join3 } from "path";
708
1373
 
709
1374
  // src/git/root.ts
710
1375
  import { execa as execa2 } from "execa";
711
- import { dirname, isAbsolute, join, resolve } from "path";
1376
+ import { dirname, isAbsolute, join as join2, resolve as resolve3 } from "path";
712
1377
 
713
1378
  // src/config/defaults.ts
714
1379
  var DEFAULT_CONFIG = {
@@ -779,7 +1444,7 @@ async function detectMainRepoRoot(cwd = process.cwd(), deps = defaultDeps) {
779
1444
  };
780
1445
  }
781
1446
  const commonDir = extractStdout(commonDirResult.stdout);
782
- const absoluteCommonDir = isAbsolute(commonDir) ? commonDir : resolve(toplevel, commonDir);
1447
+ const absoluteCommonDir = isAbsolute(commonDir) ? commonDir : resolve3(toplevel, commonDir);
783
1448
  const mainRepoRoot = dirname(absoluteCommonDir);
784
1449
  return {
785
1450
  root: mainRepoRoot,
@@ -799,24 +1464,52 @@ async function resolveSessionConfig(options = {}) {
799
1464
  if (sessionsDir) {
800
1465
  return {
801
1466
  config: {
802
- todoDir: join(sessionsDir, statusDirs2.todo),
803
- doingDir: join(sessionsDir, statusDirs2.doing),
804
- archiveDir: join(sessionsDir, statusDirs2.archive)
1467
+ todoDir: join2(sessionsDir, statusDirs2.todo),
1468
+ doingDir: join2(sessionsDir, statusDirs2.doing),
1469
+ archiveDir: join2(sessionsDir, statusDirs2.archive)
805
1470
  }
806
1471
  };
807
1472
  }
808
1473
  const gitResult = await detectMainRepoRoot(cwd, deps);
809
- const baseDir = join(gitResult.root, DEFAULT_CONFIG.sessions.dir);
1474
+ const baseDir = join2(gitResult.root, DEFAULT_CONFIG.sessions.dir);
810
1475
  return {
811
1476
  config: {
812
- todoDir: join(baseDir, statusDirs2.todo),
813
- doingDir: join(baseDir, statusDirs2.doing),
814
- archiveDir: join(baseDir, statusDirs2.archive)
1477
+ todoDir: join2(baseDir, statusDirs2.todo),
1478
+ doingDir: join2(baseDir, statusDirs2.doing),
1479
+ archiveDir: join2(baseDir, statusDirs2.archive)
815
1480
  },
816
1481
  warning: gitResult.warning
817
1482
  };
818
1483
  }
819
1484
 
1485
+ // src/session/archive.ts
1486
+ var SESSION_FILE_EXTENSION = ".md";
1487
+ var ARCHIVABLE_DIR_KEY = {
1488
+ todo: "todoDir",
1489
+ doing: "doingDir"
1490
+ };
1491
+ function buildArchivePaths(sessionId, currentStatus, config) {
1492
+ const filename = `${sessionId}${SESSION_FILE_EXTENSION}`;
1493
+ const dirKey = ARCHIVABLE_DIR_KEY[currentStatus];
1494
+ const sourceDir = config[dirKey];
1495
+ return {
1496
+ source: `${sourceDir}/${filename}`,
1497
+ target: `${config.archiveDir}/${filename}`
1498
+ };
1499
+ }
1500
+ var ARCHIVE_SEARCH_ORDER = ["todo", "doing"];
1501
+ function findSessionForArchive(existingPaths) {
1502
+ if (existingPaths.archive !== null) {
1503
+ return null;
1504
+ }
1505
+ for (const status of ARCHIVE_SEARCH_ORDER) {
1506
+ if (existingPaths[status] !== null) {
1507
+ return { status, path: existingPaths[status] };
1508
+ }
1509
+ }
1510
+ return null;
1511
+ }
1512
+
820
1513
  // src/session/batch.ts
821
1514
  var BatchError = class extends Error {
822
1515
  results;
@@ -910,39 +1603,36 @@ var SessionAlreadyArchivedError = class extends Error {
910
1603
  this.sessionId = sessionId;
911
1604
  }
912
1605
  };
913
- async function resolveArchivePaths(sessionId, config) {
914
- const filename = `${sessionId}.md`;
915
- const todoPath = join2(config.todoDir, filename);
916
- const doingPath = join2(config.doingDir, filename);
917
- const archivePath = join2(config.archiveDir, filename);
918
- try {
919
- const archiveStats = await stat(archivePath);
920
- if (archiveStats.isFile()) {
921
- throw new SessionAlreadyArchivedError(sessionId);
922
- }
923
- } catch (error) {
924
- if (error instanceof Error && "code" in error && error.code !== "ENOENT") {
925
- throw error;
926
- }
927
- if (error instanceof SessionAlreadyArchivedError) {
928
- throw error;
929
- }
930
- }
1606
+ async function fileExists(path9) {
931
1607
  try {
932
- const todoStats = await stat(todoPath);
933
- if (todoStats.isFile()) {
934
- return { source: todoPath, target: archivePath };
935
- }
1608
+ const stats = await stat(path9);
1609
+ return stats.isFile();
936
1610
  } catch {
1611
+ return false;
937
1612
  }
938
- try {
939
- const doingStats = await stat(doingPath);
940
- if (doingStats.isFile()) {
941
- return { source: doingPath, target: archivePath };
942
- }
943
- } catch {
1613
+ }
1614
+ async function probeSessionPaths(sessionId, config) {
1615
+ const filename = `${sessionId}${SESSION_FILE_EXTENSION}`;
1616
+ const todoPath = join3(config.todoDir, filename);
1617
+ const doingPath = join3(config.doingDir, filename);
1618
+ const archivePath = join3(config.archiveDir, filename);
1619
+ return {
1620
+ todo: await fileExists(todoPath) ? todoPath : null,
1621
+ doing: await fileExists(doingPath) ? doingPath : null,
1622
+ archive: await fileExists(archivePath) ? archivePath : null
1623
+ };
1624
+ }
1625
+ async function resolveArchivePaths(sessionId, config) {
1626
+ const existingPaths = await probeSessionPaths(sessionId, config);
1627
+ if (existingPaths.archive !== null) {
1628
+ throw new SessionAlreadyArchivedError(sessionId);
1629
+ }
1630
+ const location = findSessionForArchive(existingPaths);
1631
+ if (!location) {
1632
+ throw new SessionNotFoundError(sessionId);
944
1633
  }
945
- throw new SessionNotFoundError(sessionId);
1634
+ const paths = buildArchivePaths(sessionId, location.status, config);
1635
+ return paths;
946
1636
  }
947
1637
  async function archiveSingle(sessionId, config) {
948
1638
  const { source, target } = await resolveArchivePaths(sessionId, config);
@@ -961,7 +1651,7 @@ import { stat as stat2, unlink } from "fs/promises";
961
1651
 
962
1652
  // src/session/delete.ts
963
1653
  function resolveDeletePath(sessionId, existingPaths) {
964
- const matchingPath = existingPaths.find((path8) => path8.includes(sessionId));
1654
+ const matchingPath = existingPaths.find((path9) => path9.includes(sessionId));
965
1655
  if (!matchingPath) {
966
1656
  throw new SessionNotFoundError(sessionId);
967
1657
  }
@@ -969,10 +1659,10 @@ function resolveDeletePath(sessionId, existingPaths) {
969
1659
  }
970
1660
 
971
1661
  // src/session/show.ts
972
- import { join as join3 } from "path";
1662
+ import { join as join4 } from "path";
973
1663
 
974
1664
  // src/session/list.ts
975
- import { parse as parseYaml } from "yaml";
1665
+ import { parse as parseYaml2 } from "yaml";
976
1666
 
977
1667
  // src/session/timestamp.ts
978
1668
  var SESSION_ID_PATTERN = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})$/;
@@ -1032,7 +1722,7 @@ function parseSessionMetadata(content) {
1032
1722
  };
1033
1723
  }
1034
1724
  try {
1035
- const parsed = parseYaml(match[1]);
1725
+ const parsed = parseYaml2(match[1]);
1036
1726
  if (!parsed || typeof parsed !== "object") {
1037
1727
  return {
1038
1728
  priority: DEFAULT_PRIORITY,
@@ -1097,9 +1787,9 @@ function sortSessions(sessions) {
1097
1787
  // src/session/show.ts
1098
1788
  var { dir: sessionsBaseDir, statusDirs } = DEFAULT_CONFIG.sessions;
1099
1789
  var DEFAULT_SESSION_CONFIG = {
1100
- todoDir: join3(sessionsBaseDir, statusDirs.todo),
1101
- doingDir: join3(sessionsBaseDir, statusDirs.doing),
1102
- archiveDir: join3(sessionsBaseDir, statusDirs.archive)
1790
+ todoDir: join4(sessionsBaseDir, statusDirs.todo),
1791
+ doingDir: join4(sessionsBaseDir, statusDirs.doing),
1792
+ archiveDir: join4(sessionsBaseDir, statusDirs.archive)
1103
1793
  };
1104
1794
  var SEARCH_ORDER = [...SESSION_STATUSES];
1105
1795
  function resolveSessionPaths(id, config = DEFAULT_SESSION_CONFIG) {
@@ -1136,11 +1826,11 @@ function formatShowOutput(content, options) {
1136
1826
  // src/commands/session/delete.ts
1137
1827
  async function findExistingPaths(paths) {
1138
1828
  const existing = [];
1139
- for (const path8 of paths) {
1829
+ for (const path9 of paths) {
1140
1830
  try {
1141
- const stats = await stat2(path8);
1831
+ const stats = await stat2(path9);
1142
1832
  if (stats.isFile()) {
1143
- existing.push(path8);
1833
+ existing.push(path9);
1144
1834
  }
1145
1835
  } catch {
1146
1836
  }
@@ -1161,7 +1851,7 @@ async function deleteCommand(options) {
1161
1851
 
1162
1852
  // src/commands/session/handoff.ts
1163
1853
  import { mkdir as mkdir2, writeFile } from "fs/promises";
1164
- import { join as join4, resolve as resolve2 } from "path";
1854
+ import { join as join5, resolve as resolve4 } from "path";
1165
1855
 
1166
1856
  // src/session/create.ts
1167
1857
  var MIN_CONTENT_LENGTH = 1;
@@ -1172,12 +1862,6 @@ function validateSessionContent(content) {
1172
1862
  error: "Session content cannot be empty"
1173
1863
  };
1174
1864
  }
1175
- if (content.trim().length === 0) {
1176
- return {
1177
- valid: false,
1178
- error: "Session content cannot be only whitespace"
1179
- };
1180
- }
1181
1865
  return { valid: true };
1182
1866
  }
1183
1867
 
@@ -1214,8 +1898,8 @@ async function handoffCommand(options) {
1214
1898
  throw new SessionInvalidContentError(validation.error ?? "Unknown validation error");
1215
1899
  }
1216
1900
  const filename = `${sessionId}.md`;
1217
- const sessionPath = join4(config.todoDir, filename);
1218
- const absolutePath = resolve2(sessionPath);
1901
+ const sessionPath = join5(config.todoDir, filename);
1902
+ const absolutePath = resolve4(sessionPath);
1219
1903
  await mkdir2(config.todoDir, { recursive: true });
1220
1904
  await writeFile(sessionPath, fullContent, "utf-8");
1221
1905
  if (warning) {
@@ -1227,8 +1911,8 @@ async function handoffCommand(options) {
1227
1911
  }
1228
1912
 
1229
1913
  // src/commands/session/list.ts
1230
- import { readdir, readFile } from "fs/promises";
1231
- import { join as join5 } from "path";
1914
+ import { readdir, readFile as readFile3 } from "fs/promises";
1915
+ import { join as join6 } from "path";
1232
1916
  async function loadSessionsFromDir(dir, status) {
1233
1917
  try {
1234
1918
  const files = await readdir(dir);
@@ -1236,8 +1920,8 @@ async function loadSessionsFromDir(dir, status) {
1236
1920
  for (const file of files) {
1237
1921
  if (!file.endsWith(".md")) continue;
1238
1922
  const id = file.replace(".md", "");
1239
- const filePath = join5(dir, file);
1240
- const content = await readFile(filePath, "utf-8");
1923
+ const filePath = join6(dir, file);
1924
+ const content = await readFile3(filePath, "utf-8");
1241
1925
  const metadata = parseSessionMetadata(content);
1242
1926
  sessions.push({
1243
1927
  id,
@@ -1299,8 +1983,8 @@ async function listCommand(options) {
1299
1983
  }
1300
1984
 
1301
1985
  // src/commands/session/pickup.ts
1302
- import { mkdir as mkdir3, readdir as readdir2, readFile as readFile2, rename as rename2 } from "fs/promises";
1303
- import { join as join6 } from "path";
1986
+ import { mkdir as mkdir3, readdir as readdir2, readFile as readFile4, rename as rename2 } from "fs/promises";
1987
+ import { join as join7 } from "path";
1304
1988
 
1305
1989
  // src/session/pickup.ts
1306
1990
  function buildClaimPaths(sessionId, config) {
@@ -1345,8 +2029,8 @@ async function loadTodoSessions(config) {
1345
2029
  for (const file of files) {
1346
2030
  if (!file.endsWith(".md")) continue;
1347
2031
  const id = file.replace(".md", "");
1348
- const filePath = join6(config.todoDir, file);
1349
- const content = await readFile2(filePath, "utf-8");
2032
+ const filePath = join7(config.todoDir, file);
2033
+ const content = await readFile4(filePath, "utf-8");
1350
2034
  const metadata = parseSessionMetadata(content);
1351
2035
  sessions.push({
1352
2036
  id,
@@ -1385,7 +2069,7 @@ async function pickupCommand(options) {
1385
2069
  } catch (error) {
1386
2070
  throw classifyClaimError(error, sessionId);
1387
2071
  }
1388
- const content = await readFile2(paths.target, "utf-8");
2072
+ const content = await readFile4(paths.target, "utf-8");
1389
2073
  const output = formatShowOutput(content, { status: PICKUP_TARGET_STATUS });
1390
2074
  return `Claimed session <PICKUP_ID>${sessionId}</PICKUP_ID>
1391
2075
 
@@ -1393,10 +2077,30 @@ ${output}`;
1393
2077
  }
1394
2078
 
1395
2079
  // src/commands/session/prune.ts
1396
- import { readdir as readdir3, readFile as readFile3, unlink as unlink2 } from "fs/promises";
1397
- import { join as join7 } from "path";
1398
- var PRUNE_STATUS = SESSION_STATUSES[2];
2080
+ import { readdir as readdir3, readFile as readFile5, unlink as unlink2 } from "fs/promises";
2081
+ import { join as join8 } from "path";
2082
+
2083
+ // src/session/prune.ts
1399
2084
  var DEFAULT_KEEP_COUNT = 5;
2085
+ function selectSessionsToDelete(sessions, options = {}) {
2086
+ const keep = options.keep ?? DEFAULT_KEEP_COUNT;
2087
+ if (sessions.length <= keep) {
2088
+ return [];
2089
+ }
2090
+ const sorted = [...sessions].sort((a, b) => {
2091
+ const dateA = parseSessionId(a.id);
2092
+ const dateB = parseSessionId(b.id);
2093
+ if (!dateA && !dateB) return 0;
2094
+ if (!dateA) return -1;
2095
+ if (!dateB) return 1;
2096
+ return dateA.getTime() - dateB.getTime();
2097
+ });
2098
+ const deleteCount = sessions.length - keep;
2099
+ return sorted.slice(0, deleteCount);
2100
+ }
2101
+
2102
+ // src/commands/session/prune.ts
2103
+ var PRUNE_STATUS = SESSION_STATUSES[2];
1400
2104
  var PruneValidationError = class extends Error {
1401
2105
  constructor(message) {
1402
2106
  super(message);
@@ -1419,8 +2123,8 @@ async function loadArchiveSessions(config) {
1419
2123
  for (const file of files) {
1420
2124
  if (!file.endsWith(".md")) continue;
1421
2125
  const id = file.replace(".md", "");
1422
- const filePath = join7(config.archiveDir, file);
1423
- const content = await readFile3(filePath, "utf-8");
2126
+ const filePath = join8(config.archiveDir, file);
2127
+ const content = await readFile5(filePath, "utf-8");
1424
2128
  const metadata = parseSessionMetadata(content);
1425
2129
  sessions.push({
1426
2130
  id,
@@ -1437,20 +2141,13 @@ async function loadArchiveSessions(config) {
1437
2141
  throw error;
1438
2142
  }
1439
2143
  }
1440
- function selectSessionsToPrune(sessions, keep) {
1441
- const sorted = sortSessions(sessions);
1442
- if (sorted.length <= keep) {
1443
- return [];
1444
- }
1445
- return sorted.slice(keep);
1446
- }
1447
2144
  async function pruneCommand(options) {
1448
2145
  validatePruneOptions(options);
1449
2146
  const keep = options.keep ?? DEFAULT_KEEP_COUNT;
1450
2147
  const dryRun = options.dryRun ?? false;
1451
2148
  const { config } = await resolveSessionConfig({ sessionsDir: options.sessionsDir });
1452
2149
  const sessions = await loadArchiveSessions(config);
1453
- const toPrune = selectSessionsToPrune(sessions, keep);
2150
+ const toPrune = selectSessionsToDelete(sessions, { keep });
1454
2151
  if (toPrune.length === 0) {
1455
2152
  return `No sessions to prune. ${sessions.length} sessions kept.`;
1456
2153
  }
@@ -1512,19 +2209,7 @@ async function loadDoingSessions(config) {
1512
2209
  throw error;
1513
2210
  }
1514
2211
  }
1515
- async function releaseCommand(options) {
1516
- const { config } = await resolveSessionConfig({ sessionsDir: options.sessionsDir });
1517
- let sessionId;
1518
- if (options.sessionId) {
1519
- sessionId = options.sessionId;
1520
- } else {
1521
- const sessions = await loadDoingSessions(config);
1522
- const current = findCurrentSession(sessions);
1523
- if (!current) {
1524
- throw new SessionNotClaimedError("(none)");
1525
- }
1526
- sessionId = current.id;
1527
- }
2212
+ async function releaseSingle(sessionId, config) {
1528
2213
  const paths = buildReleasePaths(sessionId, config);
1529
2214
  try {
1530
2215
  await rename3(paths.source, paths.target);
@@ -1537,9 +2222,22 @@ async function releaseCommand(options) {
1537
2222
  return `Released session: ${sessionId}
1538
2223
  Session returned to todo directory.`;
1539
2224
  }
2225
+ async function releaseCommand(options) {
2226
+ const { config } = await resolveSessionConfig({ sessionsDir: options.sessionsDir });
2227
+ let ids = options.sessionIds;
2228
+ if (ids.length === 0) {
2229
+ const sessions = await loadDoingSessions(config);
2230
+ const current = findCurrentSession(sessions);
2231
+ if (!current) {
2232
+ throw new SessionNotClaimedError("(none)");
2233
+ }
2234
+ ids = [current.id];
2235
+ }
2236
+ return processBatch(ids, (id) => releaseSingle(id, config));
2237
+ }
1540
2238
 
1541
2239
  // src/commands/session/show.ts
1542
- import { readFile as readFile4, stat as stat3 } from "fs/promises";
2240
+ import { readFile as readFile6, stat as stat3 } from "fs/promises";
1543
2241
  async function findExistingPath(paths, _config) {
1544
2242
  for (let i = 0; i < paths.length; i++) {
1545
2243
  const filePath = paths[i];
@@ -1559,10 +2257,10 @@ async function showSingle(sessionId, config) {
1559
2257
  if (!found) {
1560
2258
  throw new SessionNotFoundError(sessionId);
1561
2259
  }
1562
- const content = await readFile4(found.path, "utf-8");
2260
+ const content = await readFile6(found.path, "utf-8");
1563
2261
  return formatShowOutput(content, { status: found.status });
1564
2262
  }
1565
- async function showCommand(options) {
2263
+ async function showCommand2(options) {
1566
2264
  const { config } = await resolveSessionConfig({ sessionsDir: options.sessionsDir });
1567
2265
  return processBatch(options.sessionIds, (id) => showSingle(id, config));
1568
2266
  }
@@ -1630,17 +2328,17 @@ async function readStdin() {
1630
2328
  if (process.stdin.isTTY) {
1631
2329
  return void 0;
1632
2330
  }
1633
- return new Promise((resolve3) => {
2331
+ return new Promise((resolve6) => {
1634
2332
  let data = "";
1635
2333
  process.stdin.setEncoding("utf-8");
1636
2334
  process.stdin.on("data", (chunk) => {
1637
2335
  data += chunk;
1638
2336
  });
1639
2337
  process.stdin.on("end", () => {
1640
- resolve3(data.trim() || void 0);
2338
+ resolve6(data.trim() || void 0);
1641
2339
  });
1642
2340
  process.stdin.on("error", () => {
1643
- resolve3(void 0);
2341
+ resolve6(void 0);
1644
2342
  });
1645
2343
  });
1646
2344
  }
@@ -1675,7 +2373,7 @@ function registerSessionCommands(sessionCmd) {
1675
2373
  });
1676
2374
  sessionCmd.command("show <id...>").description("Show session content").option("--sessions-dir <path>", "Custom sessions directory").action(async (ids, options) => {
1677
2375
  try {
1678
- const output = await showCommand({
2376
+ const output = await showCommand2({
1679
2377
  sessionIds: ids,
1680
2378
  sessionsDir: options.sessionsDir
1681
2379
  });
@@ -1700,10 +2398,10 @@ function registerSessionCommands(sessionCmd) {
1700
2398
  handleError(error);
1701
2399
  }
1702
2400
  });
1703
- sessionCmd.command("release [id]").description("Release a session (move from doing to todo)").option("--sessions-dir <path>", "Custom sessions directory").action(async (id, options) => {
2401
+ sessionCmd.command("release [ids...]").description("Release one or more sessions (move from doing to todo)").option("--sessions-dir <path>", "Custom sessions directory").action(async (ids, options) => {
1704
2402
  try {
1705
2403
  const output = await releaseCommand({
1706
- sessionId: id,
2404
+ sessionIds: ids,
1707
2405
  sessionsDir: options.sessionsDir
1708
2406
  });
1709
2407
  console.log(output);
@@ -1777,8 +2475,7 @@ var sessionDomain = {
1777
2475
  };
1778
2476
 
1779
2477
  // src/domains/spec/index.ts
1780
- import { access as access2 } from "fs/promises";
1781
- import { readFile as readFile5, writeFile as writeFile2 } from "fs/promises";
2478
+ import { access as access2, readFile as readFile7, writeFile as writeFile2 } from "fs/promises";
1782
2479
 
1783
2480
  // src/scanner/scanner.ts
1784
2481
  import path5 from "path";
@@ -1897,34 +2594,28 @@ var StatusDeterminationError = class extends Error {
1897
2594
  };
1898
2595
  async function getWorkItemStatus(workItemPath) {
1899
2596
  try {
1900
- try {
1901
- await access(workItemPath);
1902
- } catch (error) {
1903
- if (error.code === "ENOENT") {
1904
- throw new Error(`Work item not found: ${workItemPath}`);
1905
- }
1906
- throw error;
1907
- }
1908
2597
  const testsPath = path6.join(workItemPath, "tests");
1909
- let hasTests;
2598
+ let entries;
1910
2599
  try {
1911
- await access(testsPath);
1912
- hasTests = true;
2600
+ entries = await readdir5(testsPath);
1913
2601
  } catch (error) {
1914
2602
  if (error.code === "ENOENT") {
1915
- hasTests = false;
1916
- } else {
1917
- throw error;
2603
+ try {
2604
+ await access(workItemPath);
2605
+ } catch (workItemError) {
2606
+ if (workItemError.code === "ENOENT") {
2607
+ throw new Error(`Work item not found: ${workItemPath}`);
2608
+ }
2609
+ throw workItemError;
2610
+ }
2611
+ return determineStatus({
2612
+ hasTestsDir: false,
2613
+ hasDoneMd: false,
2614
+ testsIsEmpty: true
2615
+ });
1918
2616
  }
2617
+ throw error;
1919
2618
  }
1920
- if (!hasTests) {
1921
- return determineStatus({
1922
- hasTestsDir: false,
1923
- hasDoneMd: false,
1924
- testsIsEmpty: true
1925
- });
1926
- }
1927
- const entries = await readdir5(testsPath);
1928
2619
  const hasDone = entries.includes("DONE.md");
1929
2620
  if (hasDone) {
1930
2621
  const donePath = path6.join(testsPath, "DONE.md");
@@ -2044,6 +2735,14 @@ function rollupStatus(nodes) {
2044
2735
  }
2045
2736
 
2046
2737
  // src/commands/spec/next.ts
2738
+ var EMPTY_WORK_ITEMS_MESSAGE = "No work items found in specs/work/doing";
2739
+ var ALL_COMPLETE_MESSAGE = "All work items are complete! \u{1F389}";
2740
+ var NEXT_WORK_ITEM_HEADING = "Next work item:";
2741
+ var STATUS_LABEL = "Status";
2742
+ var PATH_LABEL = "Path";
2743
+ var INDENT = " ";
2744
+ var BLANK_LINE = "";
2745
+ var PATH_SEPARATOR = " > ";
2047
2746
  function findNextWorkItem(tree) {
2048
2747
  return findFirstNonDoneLeaf(tree.nodes);
2049
2748
  }
@@ -2053,46 +2752,18 @@ function findFirstNonDoneLeaf(nodes) {
2053
2752
  if (node.status !== "DONE") {
2054
2753
  return node;
2055
2754
  }
2056
- } else {
2057
- const found = findFirstNonDoneLeaf(node.children);
2058
- if (found) {
2059
- return found;
2060
- }
2755
+ continue;
2756
+ }
2757
+ const found = findFirstNonDoneLeaf(node.children);
2758
+ if (found !== null) {
2759
+ return found;
2061
2760
  }
2062
2761
  }
2063
2762
  return null;
2064
2763
  }
2065
2764
  function formatWorkItemName(node) {
2066
- const displayNum = node.kind === "capability" ? node.number + 1 : node.number;
2067
- return `${node.kind}-${displayNum}_${node.slug}`;
2068
- }
2069
- async function nextCommand(options = {}) {
2070
- const cwd = options.cwd || process.cwd();
2071
- const scanner = new Scanner(cwd, DEFAULT_CONFIG);
2072
- const workItems = await scanner.scan();
2073
- if (workItems.length === 0) {
2074
- return `No work items found in ${DEFAULT_CONFIG.specs.root}/${DEFAULT_CONFIG.specs.work.dir}/${DEFAULT_CONFIG.specs.work.statusDirs.doing}`;
2075
- }
2076
- const tree = await buildTree(workItems);
2077
- const next = findNextWorkItem(tree);
2078
- if (!next) {
2079
- return "All work items are complete! \u{1F389}";
2080
- }
2081
- const parents = findParents(tree.nodes, next);
2082
- const lines = [];
2083
- lines.push("Next work item:");
2084
- lines.push("");
2085
- if (parents.capability && parents.feature) {
2086
- lines.push(
2087
- ` ${formatWorkItemName(parents.capability)} > ${formatWorkItemName(parents.feature)} > ${formatWorkItemName(next)}`
2088
- );
2089
- } else {
2090
- lines.push(` ${formatWorkItemName(next)}`);
2091
- }
2092
- lines.push("");
2093
- lines.push(` Status: ${next.status}`);
2094
- lines.push(` Path: ${next.path}`);
2095
- return lines.join("\n");
2765
+ const displayNumber = node.kind === "capability" ? node.number + 1 : node.number;
2766
+ return `${node.kind}-${displayNumber}_${node.slug}`;
2096
2767
  }
2097
2768
  function findParents(nodes, target) {
2098
2769
  for (const capability of nodes) {
@@ -2106,9 +2777,32 @@ function findParents(nodes, target) {
2106
2777
  }
2107
2778
  return {};
2108
2779
  }
2780
+ async function nextCommand(options = {}) {
2781
+ const cwd = options.cwd ?? process.cwd();
2782
+ const scanner = new Scanner(cwd, DEFAULT_CONFIG);
2783
+ const workItems = await scanner.scan();
2784
+ if (workItems.length === 0) {
2785
+ return EMPTY_WORK_ITEMS_MESSAGE;
2786
+ }
2787
+ const tree = await buildTree(workItems);
2788
+ const next = findNextWorkItem(tree);
2789
+ if (next === null) {
2790
+ return ALL_COMPLETE_MESSAGE;
2791
+ }
2792
+ const parents = findParents(tree.nodes, next);
2793
+ const pathLine = parents.capability !== void 0 && parents.feature !== void 0 ? `${INDENT}${formatWorkItemName(parents.capability)}${PATH_SEPARATOR}${formatWorkItemName(parents.feature)}${PATH_SEPARATOR}${formatWorkItemName(next)}` : `${INDENT}${formatWorkItemName(next)}`;
2794
+ return [
2795
+ NEXT_WORK_ITEM_HEADING,
2796
+ BLANK_LINE,
2797
+ pathLine,
2798
+ BLANK_LINE,
2799
+ `${INDENT}${STATUS_LABEL}: ${next.status}`,
2800
+ `${INDENT}${PATH_LABEL}: ${next.path}`
2801
+ ].join("\n");
2802
+ }
2109
2803
 
2110
2804
  // src/reporter/json.ts
2111
- var JSON_INDENT = 2;
2805
+ var JSON_INDENT3 = 2;
2112
2806
  function formatJSON(tree, config) {
2113
2807
  const capabilities = tree.nodes.map((node) => nodeToJSON(node));
2114
2808
  const summary = calculateSummary(tree);
@@ -2120,7 +2814,7 @@ function formatJSON(tree, config) {
2120
2814
  summary,
2121
2815
  capabilities
2122
2816
  };
2123
- return JSON.stringify(output, null, JSON_INDENT);
2817
+ return JSON.stringify(output, null, JSON_INDENT3);
2124
2818
  }
2125
2819
  function nodeToJSON(node) {
2126
2820
  const displayNumber = getDisplayNumber(node);
@@ -2302,26 +2996,32 @@ function formatStatus(status) {
2302
2996
  }
2303
2997
 
2304
2998
  // src/commands/spec/status.ts
2999
+ var DEFAULT_FORMAT = "text";
3000
+ function buildMissingDirectoryMessage() {
3001
+ const {
3002
+ root,
3003
+ work: {
3004
+ dir,
3005
+ statusDirs: { doing }
3006
+ }
3007
+ } = DEFAULT_CONFIG.specs;
3008
+ return `Directory ${root}/${dir}/${doing} not found.`;
3009
+ }
2305
3010
  async function statusCommand(options = {}) {
2306
- const cwd = options.cwd || process.cwd();
2307
- const format2 = options.format || "text";
3011
+ const cwd = options.cwd ?? process.cwd();
3012
+ const format2 = options.format ?? DEFAULT_FORMAT;
2308
3013
  const scanner = new Scanner(cwd, DEFAULT_CONFIG);
2309
3014
  let workItems;
2310
3015
  try {
2311
3016
  workItems = await scanner.scan();
2312
3017
  } catch (error) {
2313
3018
  if (error instanceof Error && error.message.includes("ENOENT")) {
2314
- const doingPath = `${DEFAULT_CONFIG.specs.root}/${DEFAULT_CONFIG.specs.work.dir}/${DEFAULT_CONFIG.specs.work.statusDirs.doing}`;
2315
- throw new Error(
2316
- `Directory ${doingPath} not found.
2317
-
2318
- This command is for legacy specs/ projects. For CODE framework projects, check the spx/ directory for specifications.`
2319
- );
3019
+ throw new Error(buildMissingDirectoryMessage());
2320
3020
  }
2321
3021
  throw error;
2322
3022
  }
2323
3023
  if (workItems.length === 0) {
2324
- return `No work items found in ${DEFAULT_CONFIG.specs.root}/${DEFAULT_CONFIG.specs.work.dir}/${DEFAULT_CONFIG.specs.work.statusDirs.doing}`;
3024
+ return "No work items found in specs/work/doing";
2325
3025
  }
2326
3026
  const tree = await buildTree(workItems);
2327
3027
  switch (format2) {
@@ -2332,17 +3032,16 @@ This command is for legacy specs/ projects. For CODE framework projects, check t
2332
3032
  case "table":
2333
3033
  return formatTable(tree);
2334
3034
  case "text":
2335
- default:
2336
3035
  return formatText(tree);
2337
3036
  }
2338
3037
  }
2339
3038
 
2340
3039
  // src/spec/apply/exclude/adapters/detect.ts
2341
- import { join as join8 } from "path";
3040
+ import { join as join9 } from "path";
2342
3041
 
2343
3042
  // src/spec/apply/exclude/constants.ts
2344
3043
  var SPX_PREFIX = "spx/";
2345
- var NODE_SUFFIXES = [".outcome/", ".enabler/", ".capability/", ".feature/", ".story/"];
3044
+ var NODE_SUFFIXES2 = [".outcome/", ".enabler/", ".capability/", ".feature/", ".story/"];
2346
3045
  var COMMENT_CHAR = "#";
2347
3046
  var EXCLUDE_FILENAME = "EXCLUDE";
2348
3047
 
@@ -2362,7 +3061,7 @@ function toPyrightPath(node) {
2362
3061
  }
2363
3062
  function isExcludedEntry(val) {
2364
3063
  const hasPrefix = val.includes(SPX_PREFIX);
2365
- const hasSuffix = NODE_SUFFIXES.some((suffix) => val.includes(suffix));
3064
+ const hasSuffix = NODE_SUFFIXES2.some((suffix) => val.includes(suffix));
2366
3065
  return hasPrefix && hasSuffix;
2367
3066
  }
2368
3067
 
@@ -2465,7 +3164,7 @@ var pythonAdapter = {
2465
3164
  var ADAPTERS = [pythonAdapter];
2466
3165
  async function detectLanguage(projectRoot, deps) {
2467
3166
  for (const adapter of ADAPTERS) {
2468
- const configPath = join8(projectRoot, adapter.configFile);
3167
+ const configPath = join9(projectRoot, adapter.configFile);
2469
3168
  const exists = await deps.fileExists(configPath);
2470
3169
  if (exists) {
2471
3170
  return adapter;
@@ -2510,15 +3209,15 @@ var APPLY_HELP = buildApplyHelp();
2510
3209
  // src/spec/apply/exclude/exclude-file.ts
2511
3210
  var TOML_UNSAFE_PATTERN = /["\\\n\r\t]/;
2512
3211
  var PATH_TRAVERSAL_PATTERN = /(?:^|\/)\.\.(?:\/|$)/;
2513
- function validateNodePath(path8) {
2514
- if (path8.startsWith("/")) {
2515
- return `absolute path rejected: ${path8}`;
3212
+ function validateNodePath(path9) {
3213
+ if (path9.startsWith("/")) {
3214
+ return `absolute path rejected: ${path9}`;
2516
3215
  }
2517
- if (PATH_TRAVERSAL_PATTERN.test(path8)) {
2518
- return `path traversal rejected: ${path8}`;
3216
+ if (PATH_TRAVERSAL_PATTERN.test(path9)) {
3217
+ return `path traversal rejected: ${path9}`;
2519
3218
  }
2520
- if (TOML_UNSAFE_PATTERN.test(path8)) {
2521
- return `TOML-unsafe characters rejected: ${path8}`;
3219
+ if (TOML_UNSAFE_PATTERN.test(path9)) {
3220
+ return `TOML-unsafe characters rejected: ${path9}`;
2522
3221
  }
2523
3222
  return null;
2524
3223
  }
@@ -2527,10 +3226,10 @@ function readExcludedNodes(content) {
2527
3226
  }
2528
3227
 
2529
3228
  // src/spec/apply/exclude/command.ts
2530
- import { join as join9 } from "path";
3229
+ import { join as join10 } from "path";
2531
3230
  async function applyExcludeCommand(options) {
2532
3231
  const { cwd, deps } = options;
2533
- const excludePath = join9(cwd, SPX_PREFIX, EXCLUDE_FILENAME);
3232
+ const excludePath = join10(cwd, SPX_PREFIX, EXCLUDE_FILENAME);
2534
3233
  const excludeExists = await deps.fileExists(excludePath);
2535
3234
  if (!excludeExists) {
2536
3235
  return { exitCode: 1, output: `error: ${excludePath} not found` };
@@ -2539,7 +3238,7 @@ async function applyExcludeCommand(options) {
2539
3238
  if (!adapter) {
2540
3239
  return { exitCode: 1, output: "error: no supported config file found (checked: pyproject.toml)" };
2541
3240
  }
2542
- const configPath = join9(cwd, adapter.configFile);
3241
+ const configPath = join10(cwd, adapter.configFile);
2543
3242
  const excludeContent = await deps.readFile(excludePath);
2544
3243
  const configContent = await deps.readFile(configPath);
2545
3244
  const nodes = readExcludedNodes(excludeContent);
@@ -2554,77 +3253,541 @@ async function applyExcludeCommand(options) {
2554
3253
  output: `Updated ${adapter.configFile} from spx/EXCLUDE (${nodes.length} nodes).`
2555
3254
  };
2556
3255
  }
2557
- return { exitCode: 0, output: `${adapter.configFile} is already in sync with spx/EXCLUDE.` };
3256
+ return { exitCode: 0, output: `${adapter.configFile} is already in sync with spx/EXCLUDE.` };
3257
+ }
3258
+
3259
+ // src/domains/spec/index.ts
3260
+ var VALID_STATUS_FORMATS = [
3261
+ "text",
3262
+ "json",
3263
+ "markdown",
3264
+ "table"
3265
+ ];
3266
+ function handleCommandError(error) {
3267
+ console.error("Error:", error instanceof Error ? error.message : String(error));
3268
+ process.exit(1);
3269
+ }
3270
+ function resolveStatusFormat(options) {
3271
+ if (options.json === true) {
3272
+ return "json";
3273
+ }
3274
+ if (options.format === void 0) {
3275
+ return "text";
3276
+ }
3277
+ if (VALID_STATUS_FORMATS.includes(options.format)) {
3278
+ return options.format;
3279
+ }
3280
+ throw new Error(
3281
+ `Invalid format "${options.format}". Must be one of: ${VALID_STATUS_FORMATS.join(", ")}`
3282
+ );
3283
+ }
3284
+ function registerSpecCommands(specCmd) {
3285
+ specCmd.command("status").description("Get project status").option("--json", "Output as JSON").option("--format <format>", "Output format (text|json|markdown|table)").action(async (options) => {
3286
+ try {
3287
+ const output = await statusCommand({
3288
+ cwd: process.cwd(),
3289
+ format: resolveStatusFormat(options)
3290
+ });
3291
+ console.log(output);
3292
+ } catch (error) {
3293
+ handleCommandError(error);
3294
+ }
3295
+ });
3296
+ specCmd.command("next").description("Find next work item to work on").action(async () => {
3297
+ try {
3298
+ const output = await nextCommand({ cwd: process.cwd() });
3299
+ console.log(output);
3300
+ } catch (error) {
3301
+ handleCommandError(error);
3302
+ }
3303
+ });
3304
+ specCmd.command("apply").description("Apply spec-tree state to project configuration").addHelpText("after", APPLY_HELP).action(async () => {
3305
+ try {
3306
+ const result = await applyExcludeCommand({
3307
+ cwd: process.cwd(),
3308
+ deps: {
3309
+ readFile: (path9) => readFile7(path9, "utf-8"),
3310
+ writeFile: (path9, content) => writeFile2(path9, content, "utf-8"),
3311
+ fileExists: async (path9) => {
3312
+ try {
3313
+ await access2(path9);
3314
+ return true;
3315
+ } catch {
3316
+ return false;
3317
+ }
3318
+ }
3319
+ }
3320
+ });
3321
+ if (result.output) console.log(result.output);
3322
+ process.exit(result.exitCode);
3323
+ } catch (error) {
3324
+ handleCommandError(error);
3325
+ }
3326
+ });
3327
+ }
3328
+ var specDomain = {
3329
+ name: "spec",
3330
+ description: "Manage spec workflow",
3331
+ register: (program2) => {
3332
+ const specCmd = program2.command("spec").description("Manage spec workflow");
3333
+ registerSpecCommands(specCmd);
3334
+ }
3335
+ };
3336
+
3337
+ // src/validation/literal/allowlist-existing.ts
3338
+ import { readFile as readFile10, rename as rename4, writeFile as writeFile3 } from "fs/promises";
3339
+ import { dirname as dirname3, join as join13 } from "path";
3340
+ import { parse as tomlParse, stringify as tomlStringify } from "smol-toml";
3341
+ import { Document, isMap, parseDocument } from "yaml";
3342
+
3343
+ // src/validation/literal/index.ts
3344
+ import { readFile as readFile9 } from "fs/promises";
3345
+ import { isAbsolute as isAbsolute2, relative as relative3, resolve as resolve5 } from "path";
3346
+
3347
+ // src/validation/literal/detector.ts
3348
+ import { parse as parseTypeScript } from "@typescript-eslint/parser";
3349
+ import { visitorKeys as typescriptVisitorKeys } from "@typescript-eslint/visitor-keys";
3350
+ var REMEDIATION = {
3351
+ IMPORT_FROM_SOURCE: "import-from-source",
3352
+ EXTRACT_TO_SHARED_TEST_SUPPORT: "extract-to-shared-test-support"
3353
+ };
3354
+ var defaultVisitorKeys = typescriptVisitorKeys;
3355
+ var MODULE_NAMING_SKIP = {
3356
+ ImportDeclaration: /* @__PURE__ */ new Set(["source"]),
3357
+ ExportNamedDeclaration: /* @__PURE__ */ new Set(["source"]),
3358
+ ExportAllDeclaration: /* @__PURE__ */ new Set(["source"]),
3359
+ ImportExpression: /* @__PURE__ */ new Set(["source"]),
3360
+ TSImportType: /* @__PURE__ */ new Set(["source", "argument"]),
3361
+ TSExternalModuleReference: /* @__PURE__ */ new Set(["expression"])
3362
+ };
3363
+ var EMPTY_SKIP = /* @__PURE__ */ new Set();
3364
+ function collectLiterals(source, filename, options) {
3365
+ const ast = parseTypeScript(source, {
3366
+ loc: true,
3367
+ range: true,
3368
+ comment: false,
3369
+ jsx: true,
3370
+ ecmaVersion: "latest",
3371
+ sourceType: "module"
3372
+ });
3373
+ const out = [];
3374
+ walk(ast, filename, options, out);
3375
+ return out;
3376
+ }
3377
+ function walk(node, filename, options, out) {
3378
+ emitLiteral(node, filename, options, out);
3379
+ const keys = options.visitorKeys[node.type];
3380
+ if (!keys) {
3381
+ return;
3382
+ }
3383
+ const skip = MODULE_NAMING_SKIP[node.type] ?? EMPTY_SKIP;
3384
+ for (const key of keys) {
3385
+ if (skip.has(key)) continue;
3386
+ const child = node[key];
3387
+ if (Array.isArray(child)) {
3388
+ for (const item of child) {
3389
+ if (isNode(item)) walk(item, filename, options, out);
3390
+ }
3391
+ } else if (isNode(child)) {
3392
+ walk(child, filename, options, out);
3393
+ }
3394
+ }
3395
+ }
3396
+ function isNode(value) {
3397
+ return typeof value === "object" && value !== null && typeof value.type === "string";
3398
+ }
3399
+ function emitLiteral(node, filename, options, out) {
3400
+ const line = node.loc?.start?.line ?? 0;
3401
+ if (node.type === "Literal") {
3402
+ if (typeof node.value === "string") {
3403
+ if (node.value.length >= options.minStringLength) {
3404
+ out.push({ kind: "string", value: node.value, loc: { file: filename, line } });
3405
+ }
3406
+ } else if (typeof node.value === "number") {
3407
+ const raw = typeof node.raw === "string" ? node.raw : String(node.value);
3408
+ if (isMeaningfulNumber(raw, options.minNumberDigits)) {
3409
+ out.push({ kind: "number", value: String(node.value), loc: { file: filename, line } });
3410
+ }
3411
+ }
3412
+ return;
3413
+ }
3414
+ if (node.type === "TemplateElement") {
3415
+ const value = node.value;
3416
+ const cooked = value?.cooked ?? "";
3417
+ if (cooked.length >= options.minStringLength) {
3418
+ out.push({ kind: "string", value: cooked, loc: { file: filename, line } });
3419
+ }
3420
+ }
3421
+ }
3422
+ function isMeaningfulNumber(raw, minDigits) {
3423
+ const digits = raw.replace(/[^0-9]/g, "");
3424
+ return digits.length >= minDigits;
3425
+ }
3426
+ function buildIndex(occurrences) {
3427
+ const map = /* @__PURE__ */ new Map();
3428
+ for (const occ of occurrences) {
3429
+ const key = makeKey(occ.kind, occ.value);
3430
+ const existing = map.get(key);
3431
+ if (existing) {
3432
+ existing.push(occ.loc);
3433
+ } else {
3434
+ map.set(key, [occ.loc]);
3435
+ }
3436
+ }
3437
+ return map;
3438
+ }
3439
+ function makeKey(kind, value) {
3440
+ return `${kind}\0${value}`;
3441
+ }
3442
+ function splitKey(key) {
3443
+ const idx = key.indexOf("\0");
3444
+ return { kind: key.slice(0, idx), value: key.slice(idx + 1) };
3445
+ }
3446
+ function detectReuse(input) {
3447
+ const srcReuse = [];
3448
+ const testDupe = [];
3449
+ const testIndex = /* @__PURE__ */ new Map();
3450
+ for (const [file, occurrences] of input.testOccurrencesByFile) {
3451
+ for (const occ of occurrences) {
3452
+ if (input.allowlist.has(occ.value)) continue;
3453
+ const key = makeKey(occ.kind, occ.value);
3454
+ let byFile = testIndex.get(key);
3455
+ if (!byFile) {
3456
+ byFile = /* @__PURE__ */ new Map();
3457
+ testIndex.set(key, byFile);
3458
+ }
3459
+ const locsInFile = byFile.get(file);
3460
+ if (locsInFile) locsInFile.push(occ.loc);
3461
+ else byFile.set(file, [occ.loc]);
3462
+ }
3463
+ }
3464
+ for (const [key, byFile] of testIndex) {
3465
+ const { kind, value } = splitKey(key);
3466
+ const srcLocs = input.srcIndex.get(key);
3467
+ const allTestLocs = [];
3468
+ for (const locs of byFile.values()) allTestLocs.push(...locs);
3469
+ if (srcLocs && srcLocs.length > 0) {
3470
+ for (const testLoc of allTestLocs) {
3471
+ srcReuse.push({
3472
+ test: testLoc,
3473
+ kind,
3474
+ value,
3475
+ src: srcLocs,
3476
+ remediation: REMEDIATION.IMPORT_FROM_SOURCE
3477
+ });
3478
+ }
3479
+ } else if (byFile.size >= 2) {
3480
+ for (let i = 0; i < allTestLocs.length; i += 1) {
3481
+ const otherTests = [...allTestLocs.slice(0, i), ...allTestLocs.slice(i + 1)];
3482
+ testDupe.push({
3483
+ test: allTestLocs[i],
3484
+ kind,
3485
+ value,
3486
+ otherTests,
3487
+ remediation: REMEDIATION.EXTRACT_TO_SHARED_TEST_SUPPORT
3488
+ });
3489
+ }
3490
+ }
3491
+ }
3492
+ return { srcReuse, testDupe };
3493
+ }
3494
+
3495
+ // src/validation/literal/exclude.ts
3496
+ import { readFile as readFile8 } from "fs/promises";
3497
+ import { join as join11 } from "path";
3498
+ var EXCLUDE_FILENAME2 = "EXCLUDE";
3499
+ var SPX_DIR = "spx";
3500
+ async function readExcludePaths(projectRoot) {
3501
+ const filePath = join11(projectRoot, SPX_DIR, EXCLUDE_FILENAME2);
3502
+ try {
3503
+ const content = await readFile8(filePath, "utf8");
3504
+ return parseExcludeContent(content);
3505
+ } catch (err) {
3506
+ if (isNodeError(err) && err.code === "ENOENT") {
3507
+ return [];
3508
+ }
3509
+ throw err;
3510
+ }
3511
+ }
3512
+ function isUnderExcluded(relPath, excludePaths) {
3513
+ for (const raw of excludePaths) {
3514
+ const prefix = `${SPX_DIR}/${raw}`;
3515
+ if (relPath === prefix || relPath.startsWith(`${prefix}/`)) {
3516
+ return true;
3517
+ }
3518
+ }
3519
+ return false;
3520
+ }
3521
+ function parseExcludeContent(content) {
3522
+ return content.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
3523
+ }
3524
+ function isNodeError(err) {
3525
+ return typeof err === "object" && err !== null && "code" in err;
3526
+ }
3527
+
3528
+ // src/validation/literal/walker.ts
3529
+ import { readdir as readdir6 } from "fs/promises";
3530
+ import { join as join12, relative as relative2 } from "path";
3531
+ var ARTIFACT_DIRECTORIES = /* @__PURE__ */ new Set([
3532
+ "node_modules",
3533
+ "dist",
3534
+ "build",
3535
+ ".next",
3536
+ ".source",
3537
+ ".git",
3538
+ "out",
3539
+ "coverage"
3540
+ ]);
3541
+ var TYPESCRIPT_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx"]);
3542
+ var DECLARATION_SUFFIX = ".d.ts";
3543
+ async function walkTypescriptFiles(projectRoot, excludePaths) {
3544
+ const out = [];
3545
+ await walk2(projectRoot, projectRoot, excludePaths, out);
3546
+ return out;
3547
+ }
3548
+ async function walk2(dir, projectRoot, excludePaths, out) {
3549
+ let entries;
3550
+ try {
3551
+ entries = await readdir6(dir, { withFileTypes: true });
3552
+ } catch (err) {
3553
+ if (isNodeError2(err) && (err.code === "ENOENT" || err.code === "ENOTDIR")) {
3554
+ return;
3555
+ }
3556
+ throw err;
3557
+ }
3558
+ for (const entry of entries) {
3559
+ if (entry.isDirectory()) {
3560
+ if (ARTIFACT_DIRECTORIES.has(entry.name)) continue;
3561
+ const subdir = join12(dir, entry.name);
3562
+ const rel = relative2(projectRoot, subdir);
3563
+ if (isUnderExcluded(rel, excludePaths)) continue;
3564
+ await walk2(subdir, projectRoot, excludePaths, out);
3565
+ } else if (entry.isFile()) {
3566
+ if (!isTypescriptSource(entry.name)) continue;
3567
+ out.push(join12(dir, entry.name));
3568
+ }
3569
+ }
3570
+ }
3571
+ function isTypescriptSource(name) {
3572
+ if (name.endsWith(DECLARATION_SUFFIX)) return false;
3573
+ const ext = extensionOf(name);
3574
+ return TYPESCRIPT_EXTENSIONS.has(ext);
3575
+ }
3576
+ function extensionOf(name) {
3577
+ const idx = name.lastIndexOf(".");
3578
+ return idx === -1 ? "" : name.slice(idx);
3579
+ }
3580
+ function isNodeError2(err) {
3581
+ return typeof err === "object" && err !== null && "code" in err;
3582
+ }
3583
+ function isTestFile(relPath) {
3584
+ return /\.test\.tsx?$/.test(relPath);
3585
+ }
3586
+
3587
+ // src/validation/literal/index.ts
3588
+ async function validateLiteralReuse(input) {
3589
+ const config = input.config ?? literalConfigDescriptor.defaults;
3590
+ const excludePaths = await readExcludePaths(input.projectRoot);
3591
+ const candidateFiles = input.files ? input.files.map((f) => isAbsolute2(f) ? f : resolve5(input.projectRoot, f)) : await walkTypescriptFiles(input.projectRoot, excludePaths);
3592
+ const collectOptions = {
3593
+ visitorKeys: defaultVisitorKeys,
3594
+ minStringLength: config.minStringLength,
3595
+ minNumberDigits: config.minNumberDigits
3596
+ };
3597
+ const srcOccurrences = [];
3598
+ const testOccurrencesByFile = /* @__PURE__ */ new Map();
3599
+ const indexedOccurrencesByFile = /* @__PURE__ */ new Map();
3600
+ for (const abs of candidateFiles) {
3601
+ const rel = relative3(input.projectRoot, abs).split(/[\\/]/g).join("/");
3602
+ if (isUnderExcluded(rel, excludePaths)) continue;
3603
+ const content = await readSafe(abs);
3604
+ if (content === null) continue;
3605
+ const occurrences = collectLiterals(content, rel, collectOptions);
3606
+ indexedOccurrencesByFile.set(rel, occurrences);
3607
+ if (isTestFile(rel)) {
3608
+ testOccurrencesByFile.set(rel, occurrences);
3609
+ } else {
3610
+ srcOccurrences.push(...occurrences);
3611
+ }
3612
+ }
3613
+ const srcIndex = buildIndex(srcOccurrences);
3614
+ const findings = detectReuse({
3615
+ srcIndex,
3616
+ testOccurrencesByFile,
3617
+ allowlist: resolveAllowlist(config.allowlist)
3618
+ });
3619
+ return { findings, indexedOccurrencesByFile };
3620
+ }
3621
+ async function readSafe(path9) {
3622
+ try {
3623
+ return await readFile9(path9, "utf8");
3624
+ } catch (err) {
3625
+ if (typeof err === "object" && err !== null && "code" in err) {
3626
+ const code = err.code;
3627
+ if (code === "ENOENT" || code === "EISDIR") return null;
3628
+ }
3629
+ throw err;
3630
+ }
2558
3631
  }
2559
3632
 
2560
- // src/domains/spec/index.ts
2561
- function registerSpecCommands(specCmd) {
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) => {
2563
- try {
2564
- let format2 = "text";
2565
- if (options.json) {
2566
- format2 = "json";
2567
- } else if (options.format) {
2568
- const validFormats = ["text", "json", "markdown", "table"];
2569
- if (validFormats.includes(options.format)) {
2570
- format2 = options.format;
2571
- } else {
2572
- console.error(
2573
- `Error: Invalid format "${options.format}". Must be one of: ${validFormats.join(", ")}`
2574
- );
2575
- process.exit(1);
2576
- }
3633
+ // src/validation/literal/allowlist-existing.ts
3634
+ var EXIT_OK = 0;
3635
+ var EXIT_ERROR = 1;
3636
+ var DEFAULT_FORMAT2 = "yaml";
3637
+ var ALLOWLIST_INCLUDE_PATH = [LITERAL_SECTION, "allowlist", "include"];
3638
+ var TEMP_FILE_PREFIX = ".spx-allowlist-existing-";
3639
+ var TEMP_FILE_SUFFIX = ".tmp";
3640
+ var RANDOM_BASE = 36;
3641
+ var RANDOM_PREFIX_SLICE = 2;
3642
+ var RANDOM_TOKEN_LENGTH = 10;
3643
+ var JSON_INDENT4 = 2;
3644
+ var FORMAT_FILENAMES = {
3645
+ json: CONFIG_FILENAMES.json,
3646
+ yaml: CONFIG_FILENAMES.yaml,
3647
+ toml: CONFIG_FILENAMES.toml
3648
+ };
3649
+ var DETECTION_ORDER = ["json", "yaml", "toml"];
3650
+ var productionReader = {
3651
+ async read(projectRoot) {
3652
+ const detected = [];
3653
+ for (const format2 of DETECTION_ORDER) {
3654
+ const filename = FORMAT_FILENAMES[format2];
3655
+ const path9 = join13(projectRoot, filename);
3656
+ try {
3657
+ const raw = await readFile10(path9, "utf8");
3658
+ detected.push({ path: path9, format: format2, raw });
3659
+ } catch (error) {
3660
+ if (!isFileNotFound2(error)) throw error;
2577
3661
  }
2578
- const output = await statusCommand({ cwd: process.cwd(), format: format2 });
2579
- console.log(output);
2580
- } catch (error) {
2581
- console.error(
2582
- "Error:",
2583
- error instanceof Error ? error.message : String(error)
2584
- );
2585
- process.exit(1);
2586
3662
  }
2587
- });
2588
- specCmd.command("next").description("Find next work item to work on").action(async () => {
2589
- try {
2590
- const output = await nextCommand({ cwd: process.cwd() });
2591
- console.log(output);
2592
- } catch (error) {
2593
- console.error(
2594
- "Error:",
2595
- error instanceof Error ? error.message : String(error)
2596
- );
2597
- process.exit(1);
3663
+ if (detected.length === 0) return { kind: "absent" };
3664
+ if (detected.length > 1) {
3665
+ return { kind: "ambiguous", detected: detected.map((file) => FORMAT_FILENAMES[file.format]) };
2598
3666
  }
3667
+ return { kind: "ok", file: detected[0] };
3668
+ }
3669
+ };
3670
+ var productionWriter = {
3671
+ async write(filePath, content) {
3672
+ const dir = dirname3(filePath);
3673
+ const random = Math.random().toString(RANDOM_BASE).slice(RANDOM_PREFIX_SLICE, RANDOM_PREFIX_SLICE + RANDOM_TOKEN_LENGTH);
3674
+ const tmpPath = join13(dir, `${TEMP_FILE_PREFIX}${random}${TEMP_FILE_SUFFIX}`);
3675
+ await writeFile3(tmpPath, content, "utf8");
3676
+ await rename4(tmpPath, filePath);
3677
+ }
3678
+ };
3679
+ async function allowlistExisting(options) {
3680
+ const reader = options.reader ?? productionReader;
3681
+ const writer = options.writer ?? productionWriter;
3682
+ const readResult = await reader.read(options.projectRoot);
3683
+ if (readResult.kind === "ambiguous") {
3684
+ const names = readResult.detected.join(", ");
3685
+ return { exitCode: EXIT_ERROR, output: `multiple config files found: ${names}` };
3686
+ }
3687
+ const currentLiteralConfig = readCurrentLiteralConfig(readResult);
3688
+ const detection = await validateLiteralReuse({
3689
+ projectRoot: options.projectRoot,
3690
+ config: currentLiteralConfig
2599
3691
  });
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
- });
3692
+ const findingValues = collectFindingValues(detection.findings);
3693
+ const updatedInclude = computeUpdatedInclude(
3694
+ currentLiteralConfig.allowlist.include,
3695
+ findingValues
3696
+ );
3697
+ const target = readResult.kind === "ok" ? readResult.file : {
3698
+ path: join13(options.projectRoot, FORMAT_FILENAMES[DEFAULT_FORMAT2]),
3699
+ format: DEFAULT_FORMAT2,
3700
+ raw: ""
3701
+ };
3702
+ const serialized = serializeWithUpdatedInclude(target, updatedInclude);
3703
+ await writer.write(target.path, serialized);
3704
+ return { exitCode: EXIT_OK, output: "" };
3705
+ }
3706
+ function readCurrentLiteralConfig(read) {
3707
+ if (read.kind !== "ok") return literalConfigDescriptor.defaults;
3708
+ const sections = parseSections2(read.file);
3709
+ const literalRaw = sections[LITERAL_SECTION];
3710
+ if (literalRaw === void 0) return literalConfigDescriptor.defaults;
3711
+ const validated = literalConfigDescriptor.validate(literalRaw);
3712
+ return validated.ok ? validated.value : literalConfigDescriptor.defaults;
3713
+ }
3714
+ function parseSections2(file) {
3715
+ if (file.raw.trim() === "") return {};
3716
+ switch (file.format) {
3717
+ case "json":
3718
+ return JSON.parse(file.raw);
3719
+ case "yaml": {
3720
+ const parsed = parseDocument(file.raw).toJS({ maxAliasCount: 0 });
3721
+ return parsed ?? {};
3722
+ }
3723
+ case "toml":
3724
+ return tomlParse(file.raw);
3725
+ }
3726
+ }
3727
+ function collectFindingValues(findings) {
3728
+ const values = /* @__PURE__ */ new Set();
3729
+ for (const finding of findings.srcReuse) values.add(finding.value);
3730
+ for (const finding of findings.testDupe) values.add(finding.value);
3731
+ return [...values];
3732
+ }
3733
+ function computeUpdatedInclude(existing, findingValues) {
3734
+ const existingArr = existing ?? [];
3735
+ const existingSet = new Set(existingArr);
3736
+ const additions = findingValues.filter((value) => !existingSet.has(value)).sort();
3737
+ return [...existingArr, ...additions];
3738
+ }
3739
+ function serializeWithUpdatedInclude(target, include) {
3740
+ switch (target.format) {
3741
+ case "yaml":
3742
+ return serializeYaml(target.raw, include);
3743
+ case "json":
3744
+ return serializeJson(target.raw, include);
3745
+ case "toml":
3746
+ return serializeToml(target.raw, include);
3747
+ }
2619
3748
  }
2620
- var specDomain = {
2621
- name: "spec",
2622
- description: "Manage spec workflow",
2623
- register: (program2) => {
2624
- const specCmd = program2.command("spec").description("Manage spec workflow");
2625
- registerSpecCommands(specCmd);
3749
+ function serializeYaml(raw, include) {
3750
+ const doc = isEffectivelyEmpty(raw, "yaml") ? new Document({}) : parseDocument(raw);
3751
+ doc.setIn([...ALLOWLIST_INCLUDE_PATH], [...include]);
3752
+ return doc.toString();
3753
+ }
3754
+ function serializeJson(raw, include) {
3755
+ const obj = isEffectivelyEmpty(raw, "json") ? {} : JSON.parse(raw);
3756
+ setNested(obj, [...ALLOWLIST_INCLUDE_PATH], [...include]);
3757
+ return JSON.stringify(obj, null, JSON_INDENT4) + "\n";
3758
+ }
3759
+ function serializeToml(raw, include) {
3760
+ const obj = isEffectivelyEmpty(raw, "toml") ? {} : tomlParse(raw);
3761
+ setNested(obj, [...ALLOWLIST_INCLUDE_PATH], [...include]);
3762
+ return tomlStringify(obj);
3763
+ }
3764
+ function isEffectivelyEmpty(raw, format2) {
3765
+ if (raw.trim() === "") return true;
3766
+ if (format2 === "yaml") {
3767
+ const doc = parseDocument(raw);
3768
+ if (doc.contents === null) return true;
3769
+ if (isMap(doc.contents) && doc.contents.items.length === 0) return true;
2626
3770
  }
2627
- };
3771
+ return false;
3772
+ }
3773
+ function setNested(target, path9, value) {
3774
+ let cursor = target;
3775
+ for (let i = 0; i < path9.length - 1; i += 1) {
3776
+ const key = path9[i];
3777
+ const existing = cursor[key];
3778
+ if (typeof existing === "object" && existing !== null && !Array.isArray(existing)) {
3779
+ cursor = existing;
3780
+ } else {
3781
+ const fresh = {};
3782
+ cursor[key] = fresh;
3783
+ cursor = fresh;
3784
+ }
3785
+ }
3786
+ cursor[path9[path9.length - 1]] = value;
3787
+ }
3788
+ function isFileNotFound2(error) {
3789
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
3790
+ }
2628
3791
 
2629
3792
  // node_modules/.pnpm/jsonc-parser@3.3.1/node_modules/jsonc-parser/lib/esm/impl/scanner.js
2630
3793
  function createScanner(text, ignoreTrivia = false) {
@@ -3486,15 +4649,15 @@ var ParseErrorCode;
3486
4649
  })(ParseErrorCode || (ParseErrorCode = {}));
3487
4650
 
3488
4651
  // src/validation/config/scope.ts
3489
- import { existsSync, readdirSync, readFileSync } from "fs";
3490
- import { join as join10 } from "path";
4652
+ import { existsSync as existsSync2, readdirSync, readFileSync } from "fs";
4653
+ import { join as join14 } from "path";
3491
4654
  var TSCONFIG_FILES = {
3492
4655
  full: "tsconfig.json",
3493
4656
  production: "tsconfig.production.json"
3494
4657
  };
3495
4658
  var defaultScopeDeps = {
3496
4659
  readFileSync,
3497
- existsSync,
4660
+ existsSync: existsSync2,
3498
4661
  readdirSync
3499
4662
  };
3500
4663
  function parseTypeScriptConfig(configPath, deps = defaultScopeDeps) {
@@ -3534,7 +4697,7 @@ function hasTypeScriptFilesRecursive(dirPath, maxDepth = 2, deps = defaultScopeD
3534
4697
  if (hasDirectTsFiles) return true;
3535
4698
  const subdirs = items.filter((item) => item.isDirectory() && !item.name.startsWith("."));
3536
4699
  for (const subdir of subdirs.slice(0, 5)) {
3537
- if (hasTypeScriptFilesRecursive(join10(dirPath, subdir.name), maxDepth - 1, deps)) {
4700
+ if (hasTypeScriptFilesRecursive(join14(dirPath, subdir.name), maxDepth - 1, deps)) {
3538
4701
  return true;
3539
4702
  }
3540
4703
  }
@@ -3595,7 +4758,7 @@ function getTypeScriptScope(scope, deps = defaultScopeDeps) {
3595
4758
  }
3596
4759
 
3597
4760
  // src/validation/discovery/tool-finder.ts
3598
- import { execSync } from "child_process";
4761
+ import { execSync as execSync2 } from "child_process";
3599
4762
  import fs6 from "fs";
3600
4763
  import { createRequire } from "module";
3601
4764
  import path7 from "path";
@@ -3643,7 +4806,7 @@ var defaultToolDiscoveryDeps = {
3643
4806
  existsSync: fs6.existsSync,
3644
4807
  whichSync: (tool) => {
3645
4808
  try {
3646
- const result = execSync(`which ${tool}`, {
4809
+ const result = execSync2(`which ${tool}`, {
3647
4810
  encoding: "utf-8",
3648
4811
  stdio: ["pipe", "pipe", "pipe"]
3649
4812
  });
@@ -3703,44 +4866,32 @@ function formatSkipMessage(stepName, result) {
3703
4866
  return TOOL_DISCOVERY.MESSAGES.SKIP_FORMAT(stepName, result.notFound.tool);
3704
4867
  }
3705
4868
 
3706
- // src/validation/steps/circular.ts
3707
- import madge from "madge";
3708
-
3709
- // src/validation/steps/constants.ts
3710
- var STEP_IDS = {
3711
- CIRCULAR: "circular-deps",
3712
- ESLINT: "eslint",
3713
- TYPESCRIPT: "typescript",
3714
- KNIP: "knip"
3715
- };
3716
- var STEP_NAMES = {
3717
- CIRCULAR: "Circular Dependencies",
3718
- ESLINT: "ESLint",
3719
- TYPESCRIPT: "TypeScript",
3720
- KNIP: "Unused Code"
3721
- };
3722
- var STEP_DESCRIPTIONS = {
3723
- CIRCULAR: "Checking for circular dependencies",
3724
- ESLINT: "Validating ESLint compliance",
3725
- TYPESCRIPT: "Validating TypeScript",
3726
- KNIP: "Detecting unused exports, dependencies, and files"
3727
- };
3728
- var CACHE_PATHS = {
3729
- ESLINT: "dist/.eslintcache",
3730
- TIMINGS: "dist/.validation-timings.json"
3731
- };
3732
- var VALIDATION_KEYS = {
3733
- TYPESCRIPT: "TYPESCRIPT",
3734
- ESLINT: "ESLINT",
3735
- KNIP: "KNIP"
3736
- };
3737
- var VALIDATION_DEFAULTS = {
3738
- [VALIDATION_KEYS.TYPESCRIPT]: true,
3739
- [VALIDATION_KEYS.ESLINT]: true,
3740
- [VALIDATION_KEYS.KNIP]: false
4869
+ // src/validation/discovery/language-finder.ts
4870
+ import fs7 from "fs";
4871
+ import path8 from "path";
4872
+ var TYPESCRIPT_MARKER = "tsconfig.json";
4873
+ var ESLINT_CONFIG_FILES = [
4874
+ "eslint.config.ts",
4875
+ "eslint.config.js",
4876
+ "eslint.config.mjs",
4877
+ "eslint.config.cjs"
4878
+ ];
4879
+ var defaultLanguageDetectionDeps = {
4880
+ existsSync: fs7.existsSync
3741
4881
  };
4882
+ function detectTypeScript(projectRoot, deps = defaultLanguageDetectionDeps) {
4883
+ const present = deps.existsSync(path8.join(projectRoot, TYPESCRIPT_MARKER));
4884
+ if (!present) {
4885
+ return { present: false };
4886
+ }
4887
+ const eslintConfigFile = ESLINT_CONFIG_FILES.find(
4888
+ (configFile) => deps.existsSync(path8.join(projectRoot, configFile))
4889
+ );
4890
+ return eslintConfigFile === void 0 ? { present: true } : { present: true, eslintConfigFile };
4891
+ }
3742
4892
 
3743
4893
  // src/validation/steps/circular.ts
4894
+ import madge from "madge";
3744
4895
  var defaultCircularDeps = {
3745
4896
  madge
3746
4897
  };
@@ -3776,34 +4927,20 @@ async function validateCircularDependencies(scope, typescriptScope, deps = defau
3776
4927
  return { success: false, error: errorMessage };
3777
4928
  }
3778
4929
  }
3779
- var circularDependencyStep = {
3780
- id: STEP_IDS.CIRCULAR,
3781
- name: STEP_NAMES.CIRCULAR,
3782
- description: STEP_DESCRIPTIONS.CIRCULAR,
3783
- enabled: (context) => !context.isFileSpecificMode && context.enabledValidations[VALIDATION_KEYS.TYPESCRIPT] === true,
3784
- execute: async (context) => {
3785
- const startTime = performance.now();
3786
- try {
3787
- const result = await validateCircularDependencies(context.scope, context.scopeConfig);
3788
- return {
3789
- success: result.success,
3790
- error: result.error,
3791
- duration: performance.now() - startTime
3792
- };
3793
- } catch (error) {
3794
- return {
3795
- success: false,
3796
- error: error instanceof Error ? error.message : String(error),
3797
- duration: performance.now() - startTime
3798
- };
3799
- }
3800
- }
3801
- };
3802
4930
 
3803
4931
  // src/commands/validation/circular.ts
4932
+ var TYPESCRIPT_ABSENT_MESSAGE = "\u23ED Skipping Circular dependencies (TypeScript not detected in project)";
3804
4933
  async function circularCommand(options) {
3805
4934
  const { cwd, quiet } = options;
3806
4935
  const startTime = Date.now();
4936
+ const tsDetection = detectTypeScript(cwd);
4937
+ if (!tsDetection.present) {
4938
+ return {
4939
+ exitCode: 0,
4940
+ output: quiet ? "" : TYPESCRIPT_ABSENT_MESSAGE,
4941
+ durationMs: Date.now() - startTime
4942
+ };
4943
+ }
3807
4944
  const toolResult = await discoverTool("madge", { projectRoot: cwd });
3808
4945
  if (!toolResult.found) {
3809
4946
  const skipMessage = formatSkipMessage("circular dependency check", toolResult);
@@ -3864,20 +5001,27 @@ var EXECUTION_MODES = {
3864
5001
  WRITE: "write"
3865
5002
  };
3866
5003
 
5004
+ // src/validation/steps/constants.ts
5005
+ var CACHE_PATHS = {
5006
+ ESLINT: "dist/.eslintcache",
5007
+ TIMINGS: "dist/.validation-timings.json"
5008
+ };
5009
+
3867
5010
  // src/validation/steps/eslint.ts
3868
5011
  var defaultEslintProcessRunner = { spawn };
5012
+ var DEFAULT_ESLINT_CONFIG_FILE = "eslint.config.ts";
3869
5013
  function buildEslintArgs(context) {
3870
- const { validatedFiles, mode, cacheFile } = context;
5014
+ const { validatedFiles, mode, cacheFile, configFile = DEFAULT_ESLINT_CONFIG_FILE } = context;
3871
5015
  const fixArg = mode === EXECUTION_MODES.WRITE ? ["--fix"] : [];
3872
5016
  const cacheArgs = ["--cache", "--cache-location", cacheFile];
3873
5017
  if (validatedFiles && validatedFiles.length > 0) {
3874
- return ["eslint", "--config", "eslint.config.ts", ...cacheArgs, ...fixArg, "--", ...validatedFiles];
5018
+ return ["eslint", "--config", configFile, ...cacheArgs, ...fixArg, "--", ...validatedFiles];
3875
5019
  }
3876
- return ["eslint", ".", "--config", "eslint.config.ts", ...cacheArgs, ...fixArg];
5020
+ return ["eslint", ".", "--config", configFile, ...cacheArgs, ...fixArg];
3877
5021
  }
3878
5022
  async function validateESLint(context, runner = defaultEslintProcessRunner) {
3879
- const { scope, validatedFiles, mode } = context;
3880
- return new Promise((resolve3) => {
5023
+ const { projectRoot, scope, validatedFiles, mode, eslintConfigFile } = context;
5024
+ return new Promise((resolve6) => {
3881
5025
  if (!validatedFiles || validatedFiles.length === 0) {
3882
5026
  if (scope === VALIDATION_SCOPES.PRODUCTION) {
3883
5027
  process.env.ESLINT_PRODUCTION_ONLY = "1";
@@ -3888,57 +5032,35 @@ async function validateESLint(context, runner = defaultEslintProcessRunner) {
3888
5032
  const eslintArgs = buildEslintArgs({
3889
5033
  validatedFiles,
3890
5034
  mode,
3891
- cacheFile: CACHE_PATHS.ESLINT
5035
+ cacheFile: CACHE_PATHS.ESLINT,
5036
+ configFile: eslintConfigFile
3892
5037
  });
3893
5038
  const eslintProcess = runner.spawn("npx", eslintArgs, {
3894
- cwd: process.cwd(),
5039
+ cwd: projectRoot,
3895
5040
  stdio: "inherit"
3896
5041
  });
3897
5042
  eslintProcess.on("close", (code) => {
3898
5043
  if (code === 0) {
3899
- resolve3({ success: true });
5044
+ resolve6({ success: true });
3900
5045
  } else {
3901
- resolve3({ success: false, error: `ESLint exited with code ${code}` });
5046
+ resolve6({ success: false, error: `ESLint exited with code ${code}` });
3902
5047
  }
3903
5048
  });
3904
5049
  eslintProcess.on("error", (error) => {
3905
- resolve3({ success: false, error: error.message });
5050
+ resolve6({ success: false, error: error.message });
3906
5051
  });
3907
5052
  });
3908
5053
  }
3909
- function validationEnabled(envVarKey, defaults = {}) {
5054
+ function validationEnabled(envVarKey, defaults2 = {}) {
3910
5055
  const envVar = `${envVarKey}_VALIDATION_ENABLED`;
3911
5056
  const explicitlyDisabled = process.env[envVar] === "0";
3912
5057
  const explicitlyEnabled = process.env[envVar] === "1";
3913
- const defaultValue = defaults[envVarKey] ?? true;
5058
+ const defaultValue = defaults2[envVarKey] ?? true;
3914
5059
  if (defaultValue) {
3915
5060
  return !explicitlyDisabled;
3916
5061
  }
3917
5062
  return explicitlyEnabled;
3918
5063
  }
3919
- var eslintStep = {
3920
- id: STEP_IDS.ESLINT,
3921
- name: STEP_NAMES.ESLINT,
3922
- description: STEP_DESCRIPTIONS.ESLINT,
3923
- enabled: (context) => context.enabledValidations[VALIDATION_KEYS.ESLINT] === true && validationEnabled(VALIDATION_KEYS.ESLINT),
3924
- execute: async (context) => {
3925
- const startTime = performance.now();
3926
- try {
3927
- const result = await validateESLint(context);
3928
- return {
3929
- success: result.success,
3930
- error: result.error,
3931
- duration: performance.now() - startTime
3932
- };
3933
- } catch (error) {
3934
- return {
3935
- success: false,
3936
- error: error instanceof Error ? error.message : String(error),
3937
- duration: performance.now() - startTime
3938
- };
3939
- }
3940
- }
3941
- };
3942
5064
 
3943
5065
  // src/validation/steps/knip.ts
3944
5066
  import { spawn as spawn2 } from "child_process";
@@ -3949,7 +5071,7 @@ async function validateKnip(typescriptScope, runner = defaultKnipProcessRunner)
3949
5071
  if (analyzeDirectories.length === 0) {
3950
5072
  return { success: true };
3951
5073
  }
3952
- return new Promise((resolve3) => {
5074
+ return new Promise((resolve6) => {
3953
5075
  const knipProcess = runner.spawn("npx", ["knip"], {
3954
5076
  cwd: process.cwd(),
3955
5077
  stdio: "pipe"
@@ -3964,17 +5086,17 @@ async function validateKnip(typescriptScope, runner = defaultKnipProcessRunner)
3964
5086
  });
3965
5087
  knipProcess.on("close", (code) => {
3966
5088
  if (code === 0) {
3967
- resolve3({ success: true });
5089
+ resolve6({ success: true });
3968
5090
  } else {
3969
5091
  const errorOutput = knipOutput || knipError || "Unused code detected";
3970
- resolve3({
5092
+ resolve6({
3971
5093
  success: false,
3972
5094
  error: errorOutput
3973
5095
  });
3974
5096
  }
3975
5097
  });
3976
5098
  knipProcess.on("error", (error) => {
3977
- resolve3({ success: false, error: error.message });
5099
+ resolve6({ success: false, error: error.message });
3978
5100
  });
3979
5101
  });
3980
5102
  } catch (error) {
@@ -3982,29 +5104,6 @@ async function validateKnip(typescriptScope, runner = defaultKnipProcessRunner)
3982
5104
  return { success: false, error: errorMessage };
3983
5105
  }
3984
5106
  }
3985
- var knipStep = {
3986
- id: STEP_IDS.KNIP,
3987
- name: STEP_NAMES.KNIP,
3988
- description: STEP_DESCRIPTIONS.KNIP,
3989
- enabled: (context) => context.enabledValidations[VALIDATION_KEYS.KNIP] === true && validationEnabled(VALIDATION_KEYS.KNIP, VALIDATION_DEFAULTS) && !context.isFileSpecificMode,
3990
- execute: async (context) => {
3991
- const startTime = performance.now();
3992
- try {
3993
- const result = await validateKnip(context.scopeConfig);
3994
- return {
3995
- success: result.success,
3996
- error: result.error,
3997
- duration: performance.now() - startTime
3998
- };
3999
- } catch (error) {
4000
- return {
4001
- success: false,
4002
- error: error instanceof Error ? error.message : String(error),
4003
- duration: performance.now() - startTime
4004
- };
4005
- }
4006
- }
4007
- };
4008
5107
 
4009
5108
  // src/commands/validation/knip.ts
4010
5109
  async function knipCommand(options) {
@@ -4032,9 +5131,26 @@ async function knipCommand(options) {
4032
5131
  }
4033
5132
 
4034
5133
  // src/commands/validation/lint.ts
5134
+ var TYPESCRIPT_ABSENT_MESSAGE2 = "\u23ED Skipping ESLint (TypeScript not detected in project)";
5135
+ var MISSING_CONFIG_MESSAGE = "ESLint config not found: project has tsconfig.json but no eslint.config.{ts,js,mjs,cjs}";
4035
5136
  async function lintCommand(options) {
4036
5137
  const { cwd, scope = "full", files, fix, quiet } = options;
4037
5138
  const startTime = Date.now();
5139
+ const tsDetection = detectTypeScript(cwd);
5140
+ if (!tsDetection.present) {
5141
+ return {
5142
+ exitCode: 0,
5143
+ output: quiet ? "" : TYPESCRIPT_ABSENT_MESSAGE2,
5144
+ durationMs: Date.now() - startTime
5145
+ };
5146
+ }
5147
+ if (tsDetection.eslintConfigFile === void 0) {
5148
+ return {
5149
+ exitCode: 1,
5150
+ output: MISSING_CONFIG_MESSAGE,
5151
+ durationMs: Date.now() - startTime
5152
+ };
5153
+ }
4038
5154
  const toolResult = await discoverTool("eslint", { projectRoot: cwd });
4039
5155
  if (!toolResult.found) {
4040
5156
  const skipMessage = formatSkipMessage("ESLint", toolResult);
@@ -4048,7 +5164,8 @@ async function lintCommand(options) {
4048
5164
  mode: fix ? "write" : "read",
4049
5165
  enabledValidations: { ESLINT: true },
4050
5166
  validatedFiles: files,
4051
- isFileSpecificMode: Boolean(files && files.length > 0)
5167
+ isFileSpecificMode: Boolean(files && files.length > 0),
5168
+ eslintConfigFile: tsDetection.eslintConfigFile
4052
5169
  };
4053
5170
  const result = await validateESLint(context);
4054
5171
  const durationMs = Date.now() - startTime;
@@ -4061,18 +5178,222 @@ async function lintCommand(options) {
4061
5178
  }
4062
5179
  }
4063
5180
 
5181
+ // src/commands/validation/literal.ts
5182
+ var EXIT_OK2 = 0;
5183
+ var EXIT_FINDINGS = 1;
5184
+ var EXIT_CONFIG_ERROR = 2;
5185
+ var TYPESCRIPT_ABSENT_MESSAGE3 = "\u23ED Skipping Literal (TypeScript not detected in project)";
5186
+ var DISABLED_MESSAGE = "\u23ED Skipping Literal (LITERAL_VALIDATION_ENABLED=0)";
5187
+ async function literalCommand(options) {
5188
+ const start = Date.now();
5189
+ const tsDetection = detectTypeScript(options.cwd);
5190
+ if (!tsDetection.present) {
5191
+ return {
5192
+ exitCode: EXIT_OK2,
5193
+ output: options.quiet ? "" : TYPESCRIPT_ABSENT_MESSAGE3,
5194
+ durationMs: Date.now() - start
5195
+ };
5196
+ }
5197
+ if (!validationEnabled("LITERAL")) {
5198
+ return {
5199
+ exitCode: EXIT_OK2,
5200
+ output: options.quiet ? "" : DISABLED_MESSAGE,
5201
+ durationMs: Date.now() - start
5202
+ };
5203
+ }
5204
+ let resolvedConfig;
5205
+ if (options.config !== void 0) {
5206
+ resolvedConfig = options.config;
5207
+ } else {
5208
+ const loaded = await resolveConfig(options.cwd, [literalConfigDescriptor]);
5209
+ if (!loaded.ok) {
5210
+ return {
5211
+ exitCode: EXIT_CONFIG_ERROR,
5212
+ output: `Literal: \u2717 config error \u2014 ${loaded.error}`,
5213
+ durationMs: Date.now() - start
5214
+ };
5215
+ }
5216
+ resolvedConfig = loaded.value[literalConfigDescriptor.section];
5217
+ }
5218
+ const result = await validateLiteralReuse({
5219
+ projectRoot: options.cwd,
5220
+ files: options.files,
5221
+ config: resolvedConfig
5222
+ });
5223
+ const totalFindings = result.findings.srcReuse.length + result.findings.testDupe.length;
5224
+ const exitCode = totalFindings === 0 ? EXIT_OK2 : EXIT_FINDINGS;
5225
+ const output = options.json ? JSON.stringify(result.findings) : options.quiet ? "" : totalFindings === 0 ? "Literal: \u2713 No findings" : `Literal: \u2717 ${totalFindings} finding${totalFindings === 1 ? "" : "s"}
5226
+ ${formatText2(result.findings)}`;
5227
+ return { exitCode, output, durationMs: Date.now() - start };
5228
+ }
5229
+ function formatText2(findings) {
5230
+ const lines = [];
5231
+ for (const f of findings.srcReuse) {
5232
+ lines.push(
5233
+ `[reuse] ${formatLoc(f.test)}: ${f.kind} literal "${f.value}" also in ${f.src.map(formatLoc).join(", ")} \u2014 import from source`
5234
+ );
5235
+ }
5236
+ for (const f of findings.testDupe) {
5237
+ lines.push(
5238
+ `[dupe] ${formatLoc(f.test)}: ${f.kind} literal "${f.value}" also in ${f.otherTests.map(formatLoc).join(", ")} \u2014 extract to shared test support`
5239
+ );
5240
+ }
5241
+ return lines.join("\n");
5242
+ }
5243
+ function formatLoc(loc) {
5244
+ return `${loc.file}:${loc.line}`;
5245
+ }
5246
+
5247
+ // src/validation/steps/markdown.ts
5248
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
5249
+ import { basename, join as join15 } from "path";
5250
+ import { main as markdownlintMain } from "markdownlint-cli2";
5251
+ import relativeLinksRule from "markdownlint-rule-relative-links";
5252
+ var DEFAULT_DIRECTORY_NAMES = ["spx", "docs"];
5253
+ var ENABLED_RULES = {
5254
+ MD001: true,
5255
+ MD003: true,
5256
+ MD009: true,
5257
+ MD010: true,
5258
+ MD025: true,
5259
+ MD047: true
5260
+ };
5261
+ var MD024_DISABLED_DIRECTORIES = ["docs"];
5262
+ var ERROR_LINE_PATTERN = /^(.+?):(\d+)(?::\d+)?\s+(.+)$/;
5263
+ function buildMarkdownlintConfig(directoryName) {
5264
+ const md024Disabled = MD024_DISABLED_DIRECTORIES.includes(
5265
+ directoryName
5266
+ );
5267
+ return {
5268
+ default: false,
5269
+ ...ENABLED_RULES,
5270
+ MD024: md024Disabled ? false : { siblings_only: true },
5271
+ customRules: [relativeLinksRule]
5272
+ };
5273
+ }
5274
+ function getDefaultDirectories(projectRoot) {
5275
+ return DEFAULT_DIRECTORY_NAMES.map((name) => join15(projectRoot, name)).filter((dir) => existsSync3(dir));
5276
+ }
5277
+ function getExcludeGlobs(spxDir) {
5278
+ const excludePath = join15(spxDir, EXCLUDE_FILENAME);
5279
+ if (!existsSync3(excludePath)) {
5280
+ return [];
5281
+ }
5282
+ const content = readFileSync2(excludePath, "utf-8");
5283
+ const nodes = readExcludedNodes(content);
5284
+ return nodes.map((node) => `${node}/**`);
5285
+ }
5286
+ var DATA_URI_PATTERN = /\bdata:/;
5287
+ function parseErrorLine(line) {
5288
+ if (DATA_URI_PATTERN.test(line)) {
5289
+ return null;
5290
+ }
5291
+ const match = ERROR_LINE_PATTERN.exec(line);
5292
+ if (!match) {
5293
+ return null;
5294
+ }
5295
+ const [, file, lineStr, detail] = match;
5296
+ return {
5297
+ file,
5298
+ line: parseInt(lineStr, 10),
5299
+ detail
5300
+ };
5301
+ }
5302
+ async function validateMarkdown(options) {
5303
+ const { directories, projectRoot } = options;
5304
+ const errors = [];
5305
+ for (const directory of directories) {
5306
+ const dirName = basename(directory);
5307
+ const config = buildMarkdownlintConfig(dirName);
5308
+ const excludeGlobs = getExcludeGlobs(directory);
5309
+ const dirErrors = await validateDirectory(directory, config, projectRoot, excludeGlobs);
5310
+ errors.push(...dirErrors);
5311
+ }
5312
+ return {
5313
+ success: errors.length === 0,
5314
+ errors
5315
+ };
5316
+ }
5317
+ async function validateDirectory(directory, config, projectRoot, ignoreGlobs = []) {
5318
+ const errors = [];
5319
+ const { customRules, ...markdownlintConfig } = config;
5320
+ const optionsOverride = {
5321
+ config: {
5322
+ ...markdownlintConfig,
5323
+ "relative-links": projectRoot ? { root_path: projectRoot } : true
5324
+ },
5325
+ customRules,
5326
+ noProgress: true,
5327
+ noBanner: true,
5328
+ ...ignoreGlobs.length > 0 ? { ignores: ignoreGlobs } : {}
5329
+ };
5330
+ await markdownlintMain({
5331
+ directory,
5332
+ argv: ["**/*.md"],
5333
+ optionsOverride,
5334
+ noImport: true,
5335
+ logMessage: () => {
5336
+ },
5337
+ logError: (message) => {
5338
+ const parsed = parseErrorLine(message);
5339
+ if (parsed) {
5340
+ errors.push({
5341
+ ...parsed,
5342
+ file: join15(directory, parsed.file)
5343
+ });
5344
+ }
5345
+ }
5346
+ });
5347
+ return errors;
5348
+ }
5349
+
5350
+ // src/commands/validation/markdown.ts
5351
+ var MARKDOWN_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".markdown"]);
5352
+ function isMarkdownOrDirectory(path9) {
5353
+ const lastDot = path9.lastIndexOf(".");
5354
+ if (lastDot < 0) return true;
5355
+ const ext = path9.slice(lastDot).toLowerCase();
5356
+ return MARKDOWN_EXTENSIONS.has(ext);
5357
+ }
5358
+ async function markdownCommand(options) {
5359
+ const { cwd, files, quiet } = options;
5360
+ const startTime = Date.now();
5361
+ const markdownScopedFiles = files?.filter(isMarkdownOrDirectory);
5362
+ const directories = markdownScopedFiles && markdownScopedFiles.length > 0 ? markdownScopedFiles : files && files.length > 0 ? [] : getDefaultDirectories(cwd);
5363
+ if (directories.length === 0) {
5364
+ const reason = files && files.length > 0 ? "no markdown files in --files scope" : "no spx/ or docs/ directories found";
5365
+ const output = quiet ? "" : `Markdown: skipped (${reason})`;
5366
+ return { exitCode: 0, output, durationMs: Date.now() - startTime };
5367
+ }
5368
+ const result = await validateMarkdown({
5369
+ directories,
5370
+ projectRoot: cwd
5371
+ });
5372
+ const durationMs = Date.now() - startTime;
5373
+ if (result.success) {
5374
+ const output = quiet ? "" : "Markdown: No issues found";
5375
+ return { exitCode: 0, output, durationMs };
5376
+ } else {
5377
+ const errorLines = result.errors.map(
5378
+ (error) => ` ${error.file}:${error.line} ${error.detail}`
5379
+ );
5380
+ const output = [`Markdown: ${result.errors.length} error(s) found`, ...errorLines].join("\n");
5381
+ return { exitCode: 1, output, durationMs };
5382
+ }
5383
+ }
5384
+
4064
5385
  // src/validation/steps/typescript.ts
4065
5386
  import { spawn as spawn3 } from "child_process";
4066
- import { existsSync as existsSync2, mkdirSync, rmSync, writeFileSync } from "fs";
5387
+ import { existsSync as existsSync4, mkdirSync, rmSync, writeFileSync } from "fs";
4067
5388
  import { mkdtemp } from "fs/promises";
4068
5389
  import { tmpdir } from "os";
4069
- import { isAbsolute as isAbsolute2, join as join11 } from "path";
5390
+ import { isAbsolute as isAbsolute3, join as join16 } from "path";
4070
5391
  var defaultTypeScriptProcessRunner = { spawn: spawn3 };
4071
5392
  var defaultTypeScriptDeps = {
4072
5393
  mkdtemp,
4073
5394
  writeFileSync,
4074
5395
  rmSync,
4075
- existsSync: existsSync2,
5396
+ existsSync: existsSync4,
4076
5397
  mkdirSync
4077
5398
  };
4078
5399
  function buildTypeScriptArgs(context) {
@@ -4080,19 +5401,19 @@ function buildTypeScriptArgs(context) {
4080
5401
  return scope === VALIDATION_SCOPES.FULL ? ["tsc", "--noEmit"] : ["tsc", "--project", configFile];
4081
5402
  }
4082
5403
  async function createFileSpecificTsconfig(scope, files, deps = defaultTypeScriptDeps) {
4083
- const tempDir = await deps.mkdtemp(join11(tmpdir(), "validate-ts-"));
4084
- const configPath = join11(tempDir, "tsconfig.json");
5404
+ const tempDir = await deps.mkdtemp(join16(tmpdir(), "validate-ts-"));
5405
+ const configPath = join16(tempDir, "tsconfig.json");
4085
5406
  const baseConfigFile = TSCONFIG_FILES[scope];
4086
5407
  const projectRoot = process.cwd();
4087
- const absoluteFiles = files.map((file) => isAbsolute2(file) ? file : join11(projectRoot, file));
5408
+ const absoluteFiles = files.map((file) => isAbsolute3(file) ? file : join16(projectRoot, file));
4088
5409
  const tempConfig = {
4089
- extends: join11(projectRoot, baseConfigFile),
5410
+ extends: join16(projectRoot, baseConfigFile),
4090
5411
  files: absoluteFiles,
4091
5412
  include: [],
4092
5413
  exclude: [],
4093
5414
  compilerOptions: {
4094
5415
  noEmit: true,
4095
- typeRoots: [join11(projectRoot, "node_modules", "@types")],
5416
+ typeRoots: [join16(projectRoot, "node_modules", "@types")],
4096
5417
  types: ["node"]
4097
5418
  }
4098
5419
  };
@@ -4112,7 +5433,7 @@ async function validateTypeScript(scope, typescriptScope, files, runner = defaul
4112
5433
  if (files && files.length > 0) {
4113
5434
  const { configPath, cleanup } = await createFileSpecificTsconfig(scope, files, deps);
4114
5435
  try {
4115
- return await new Promise((resolve3) => {
5436
+ return await new Promise((resolve6) => {
4116
5437
  const tscProcess = runner.spawn("npx", ["tsc", "--project", configPath], {
4117
5438
  cwd: process.cwd(),
4118
5439
  stdio: "inherit"
@@ -4120,14 +5441,14 @@ async function validateTypeScript(scope, typescriptScope, files, runner = defaul
4120
5441
  tscProcess.on("close", (code) => {
4121
5442
  cleanup();
4122
5443
  if (code === 0) {
4123
- resolve3({ success: true, skipped: false });
5444
+ resolve6({ success: true, skipped: false });
4124
5445
  } else {
4125
- resolve3({ success: false, error: `TypeScript exited with code ${code}` });
5446
+ resolve6({ success: false, error: `TypeScript exited with code ${code}` });
4126
5447
  }
4127
5448
  });
4128
5449
  tscProcess.on("error", (error) => {
4129
5450
  cleanup();
4130
- resolve3({ success: false, error: error.message });
5451
+ resolve6({ success: false, error: error.message });
4131
5452
  });
4132
5453
  });
4133
5454
  } catch (error) {
@@ -4139,56 +5460,37 @@ async function validateTypeScript(scope, typescriptScope, files, runner = defaul
4139
5460
  tool = "npx";
4140
5461
  tscArgs = buildTypeScriptArgs({ scope, configFile });
4141
5462
  }
4142
- return new Promise((resolve3) => {
5463
+ return new Promise((resolve6) => {
4143
5464
  const tscProcess = runner.spawn(tool, tscArgs, {
4144
5465
  cwd: process.cwd(),
4145
5466
  stdio: "inherit"
4146
5467
  });
4147
5468
  tscProcess.on("close", (code) => {
4148
5469
  if (code === 0) {
4149
- resolve3({ success: true, skipped: false });
5470
+ resolve6({ success: true, skipped: false });
4150
5471
  } else {
4151
- resolve3({ success: false, error: `TypeScript exited with code ${code}` });
5472
+ resolve6({ success: false, error: `TypeScript exited with code ${code}` });
4152
5473
  }
4153
5474
  });
4154
5475
  tscProcess.on("error", (error) => {
4155
- resolve3({ success: false, error: error.message });
5476
+ resolve6({ success: false, error: error.message });
4156
5477
  });
4157
5478
  });
4158
5479
  }
4159
- var typescriptStep = {
4160
- id: STEP_IDS.TYPESCRIPT,
4161
- name: STEP_NAMES.TYPESCRIPT,
4162
- description: STEP_DESCRIPTIONS.TYPESCRIPT,
4163
- enabled: (context) => context.enabledValidations[VALIDATION_KEYS.TYPESCRIPT] === true && validationEnabled(VALIDATION_KEYS.TYPESCRIPT),
4164
- execute: async (context) => {
4165
- const startTime = performance.now();
4166
- try {
4167
- const result = await validateTypeScript(
4168
- context.scope,
4169
- context.scopeConfig,
4170
- context.validatedFiles
4171
- );
4172
- return {
4173
- success: result.success,
4174
- error: result.error,
4175
- duration: performance.now() - startTime,
4176
- skipped: result.skipped
4177
- };
4178
- } catch (error) {
4179
- return {
4180
- success: false,
4181
- error: error instanceof Error ? error.message : String(error),
4182
- duration: performance.now() - startTime
4183
- };
4184
- }
4185
- }
4186
- };
4187
5480
 
4188
5481
  // src/commands/validation/typescript.ts
5482
+ var TYPESCRIPT_ABSENT_MESSAGE4 = "\u23ED Skipping TypeScript (TypeScript not detected in project)";
4189
5483
  async function typescriptCommand(options) {
4190
5484
  const { cwd, scope = "full", files, quiet } = options;
4191
5485
  const startTime = Date.now();
5486
+ const tsDetection = detectTypeScript(cwd);
5487
+ if (!tsDetection.present) {
5488
+ return {
5489
+ exitCode: 0,
5490
+ output: quiet ? "" : TYPESCRIPT_ABSENT_MESSAGE4,
5491
+ durationMs: Date.now() - startTime
5492
+ };
5493
+ }
4192
5494
  const toolResult = await discoverTool("typescript", { projectRoot: cwd });
4193
5495
  if (!toolResult.found) {
4194
5496
  const skipMessage = formatSkipMessage("TypeScript", toolResult);
@@ -4207,7 +5509,7 @@ async function typescriptCommand(options) {
4207
5509
  }
4208
5510
 
4209
5511
  // src/commands/validation/all.ts
4210
- var TOTAL_STEPS = 4;
5512
+ var TOTAL_STEPS = 6;
4211
5513
  function formatStepWithTiming(stepNumber, result, quiet) {
4212
5514
  if (quiet || !result.output) return "";
4213
5515
  const timing = result.durationMs === void 0 ? "" : ` (${formatDuration(result.durationMs)})`;
@@ -4233,6 +5535,14 @@ async function allCommand(options) {
4233
5535
  const tsOutput = formatStepWithTiming(4, tsResult, quiet);
4234
5536
  if (tsOutput) outputs.push(tsOutput);
4235
5537
  if (tsResult.exitCode !== 0) hasFailure = true;
5538
+ const markdownResult = await markdownCommand({ cwd, files, quiet });
5539
+ const markdownOutput = formatStepWithTiming(5, markdownResult, quiet);
5540
+ if (markdownOutput) outputs.push(markdownOutput);
5541
+ if (markdownResult.exitCode !== 0) hasFailure = true;
5542
+ const literalResult = await literalCommand({ cwd, files, quiet, json });
5543
+ const literalOutput = formatStepWithTiming(6, literalResult, quiet);
5544
+ if (literalOutput) outputs.push(literalOutput);
5545
+ if (literalResult.exitCode !== 0) hasFailure = true;
4236
5546
  const totalDurationMs = Date.now() - startTime;
4237
5547
  if (!quiet) {
4238
5548
  const summary = formatSummary({ success: !hasFailure, totalDurationMs });
@@ -4245,12 +5555,121 @@ async function allCommand(options) {
4245
5555
  };
4246
5556
  }
4247
5557
 
5558
+ // src/lib/sanitize-cli-argument.ts
5559
+ var MAX_CLI_ARGUMENT_DISPLAY_LENGTH = 120;
5560
+ var ELLIPSIS_TOKEN = "...";
5561
+ var SENTINEL_UNDEFINED = "<undefined>";
5562
+ var SENTINEL_NULL = "<null>";
5563
+ var SENTINEL_EMPTY = "<empty>";
5564
+ var CONTROL_CHAR_UPPER_BOUND = 31;
5565
+ var DEL_CHAR_CODE = 127;
5566
+ var HEX_RADIX = 16;
5567
+ var HEX_PAD = 2;
5568
+ function nonStringSentinel(type) {
5569
+ return `<non-string:${type}>`;
5570
+ }
5571
+ function sanitizeCliArgument(input) {
5572
+ if (input === void 0) return SENTINEL_UNDEFINED;
5573
+ if (input === null) return SENTINEL_NULL;
5574
+ if (typeof input !== "string") return nonStringSentinel(typeof input);
5575
+ if (input.length === 0) return SENTINEL_EMPTY;
5576
+ const escaped = escapeControlCharacters(input);
5577
+ return truncate(escaped);
5578
+ }
5579
+ function escapeControlCharacters(value) {
5580
+ let out = "";
5581
+ for (const char of value) {
5582
+ const code = char.codePointAt(0);
5583
+ if (code === void 0) continue;
5584
+ if (code <= CONTROL_CHAR_UPPER_BOUND || code === DEL_CHAR_CODE) {
5585
+ out += `\\x${code.toString(HEX_RADIX).padStart(HEX_PAD, "0")}`;
5586
+ } else {
5587
+ out += char;
5588
+ }
5589
+ }
5590
+ return out;
5591
+ }
5592
+ function truncate(value) {
5593
+ if (value.length <= MAX_CLI_ARGUMENT_DISPLAY_LENGTH) return value;
5594
+ return value.slice(0, MAX_CLI_ARGUMENT_DISPLAY_LENGTH - ELLIPSIS_TOKEN.length) + ELLIPSIS_TOKEN;
5595
+ }
5596
+
4248
5597
  // src/domains/validation/index.ts
5598
+ var validationCliDefinition = {
5599
+ domain: {
5600
+ commandName: "validation",
5601
+ alias: "v",
5602
+ description: "Run code validation tools"
5603
+ },
5604
+ subcommands: {
5605
+ typescript: {
5606
+ commandName: "typescript",
5607
+ alias: "ts",
5608
+ description: "Run TypeScript type checking"
5609
+ },
5610
+ lint: {
5611
+ commandName: "lint",
5612
+ description: "Run ESLint"
5613
+ },
5614
+ circular: {
5615
+ commandName: "circular",
5616
+ description: "Check for circular dependencies"
5617
+ },
5618
+ knip: {
5619
+ commandName: "knip",
5620
+ description: "Detect unused code"
5621
+ },
5622
+ literal: {
5623
+ commandName: "literal",
5624
+ description: "Detect cross-file literal reuse between source and tests"
5625
+ },
5626
+ markdown: {
5627
+ commandName: "markdown",
5628
+ alias: "md",
5629
+ description: "Validate markdown link integrity and structure"
5630
+ },
5631
+ all: {
5632
+ commandName: "all",
5633
+ description: "Run all validations"
5634
+ }
5635
+ },
5636
+ commanderHelpOperands: {
5637
+ subcommand: "help",
5638
+ longFlag: "--help",
5639
+ shortFlag: "-h"
5640
+ },
5641
+ diagnostics: {
5642
+ unknownSubcommand: {
5643
+ messageLabel: "unknown subcommand",
5644
+ exitCode: 1
5645
+ }
5646
+ }
5647
+ };
5648
+ var validationSubcommandOperands = Object.values(validationCliDefinition.subcommands).flatMap(
5649
+ (subcommand) => {
5650
+ const operands = [subcommand.commandName];
5651
+ if (subcommand.alias !== void 0) operands.push(subcommand.alias);
5652
+ return operands;
5653
+ }
5654
+ );
5655
+ var validationKnownOperands = /* @__PURE__ */ new Set([
5656
+ ...validationSubcommandOperands,
5657
+ ...Object.values(validationCliDefinition.commanderHelpOperands)
5658
+ ]);
5659
+ var validationOptionPrefix = validationCliDefinition.commanderHelpOperands.longFlag.slice(0, 1);
4249
5660
  function addCommonOptions(cmd) {
4250
5661
  return cmd.option("--scope <scope>", "Validation scope (full|production)", "full").option("--files <paths...>", "Specific files/directories to validate").option("--quiet", "Suppress progress output").option("--json", "Output results as JSON");
4251
5662
  }
5663
+ function addValidationSubcommand(validationCmd, definition) {
5664
+ let subcommand = validationCmd.command(definition.commandName).description(definition.description);
5665
+ if (definition.alias !== void 0) {
5666
+ subcommand = subcommand.alias(definition.alias);
5667
+ }
5668
+ return subcommand;
5669
+ }
4252
5670
  function registerValidationCommands(validationCmd) {
4253
- const tsCmd = validationCmd.command("typescript").alias("ts").description("Run TypeScript type checking").action(async (options) => {
5671
+ const { subcommands } = validationCliDefinition;
5672
+ const tsCmd = addValidationSubcommand(validationCmd, subcommands.typescript).action(async (options) => {
4254
5673
  const result = await typescriptCommand({
4255
5674
  cwd: process.cwd(),
4256
5675
  scope: options.scope,
@@ -4262,7 +5681,7 @@ function registerValidationCommands(validationCmd) {
4262
5681
  process.exit(result.exitCode);
4263
5682
  });
4264
5683
  addCommonOptions(tsCmd);
4265
- const lintCmd = validationCmd.command("lint").description("Run ESLint").option("--fix", "Auto-fix issues").action(async (options) => {
5684
+ const lintCmd = addValidationSubcommand(validationCmd, subcommands.lint).option("--fix", "Auto-fix issues").action(async (options) => {
4266
5685
  const result = await lintCommand({
4267
5686
  cwd: process.cwd(),
4268
5687
  scope: options.scope,
@@ -4275,7 +5694,7 @@ function registerValidationCommands(validationCmd) {
4275
5694
  process.exit(result.exitCode);
4276
5695
  });
4277
5696
  addCommonOptions(lintCmd);
4278
- const circularCmd = validationCmd.command("circular").description("Check for circular dependencies").action(async (options) => {
5697
+ const circularCmd = addValidationSubcommand(validationCmd, subcommands.circular).action(async (options) => {
4279
5698
  const result = await circularCommand({
4280
5699
  cwd: process.cwd(),
4281
5700
  quiet: options.quiet,
@@ -4285,7 +5704,7 @@ function registerValidationCommands(validationCmd) {
4285
5704
  process.exit(result.exitCode);
4286
5705
  });
4287
5706
  addCommonOptions(circularCmd);
4288
- const knipCmd = validationCmd.command("knip").description("Detect unused code").action(async (options) => {
5707
+ const knipCmd = addValidationSubcommand(validationCmd, subcommands.knip).action(async (options) => {
4289
5708
  const result = await knipCommand({
4290
5709
  cwd: process.cwd(),
4291
5710
  quiet: options.quiet,
@@ -4295,7 +5714,42 @@ function registerValidationCommands(validationCmd) {
4295
5714
  process.exit(result.exitCode);
4296
5715
  });
4297
5716
  addCommonOptions(knipCmd);
4298
- const allCmd = validationCmd.command("all", { isDefault: true }).description("Run all validations").option("--fix", "Auto-fix ESLint issues").action(async (options) => {
5717
+ const literalCmd = addValidationSubcommand(validationCmd, subcommands.literal).option(
5718
+ "--allowlist-existing",
5719
+ "Append every current finding's value to literal.allowlist.include and exit"
5720
+ ).addHelpText(
5721
+ "after",
5722
+ "\nEnabled for TypeScript projects by default. Set LITERAL_VALIDATION_ENABLED=0\nto skip (useful when migrating a project with many existing violations)."
5723
+ ).action(async (options) => {
5724
+ if (options.allowlistExisting) {
5725
+ const result2 = await allowlistExisting({ projectRoot: process.cwd() });
5726
+ if (result2.output) console.log(result2.output);
5727
+ process.exit(result2.exitCode);
5728
+ }
5729
+ const result = await literalCommand({
5730
+ cwd: process.cwd(),
5731
+ files: options.files,
5732
+ quiet: options.quiet,
5733
+ json: options.json
5734
+ });
5735
+ if (result.output) console.log(result.output);
5736
+ process.exit(result.exitCode);
5737
+ });
5738
+ addCommonOptions(literalCmd);
5739
+ const markdownCmd = addValidationSubcommand(validationCmd, subcommands.markdown).addHelpText(
5740
+ "after",
5741
+ "\nValidates spx/ and docs/ by default. Nodes listed in spx/EXCLUDE are\nskipped \u2014 use this for declared-state nodes whose [test] links point\nto files that do not exist yet."
5742
+ ).action(async (options) => {
5743
+ const result = await markdownCommand({
5744
+ cwd: process.cwd(),
5745
+ files: options.files,
5746
+ quiet: options.quiet
5747
+ });
5748
+ if (result.output) console.log(result.output);
5749
+ process.exit(result.exitCode);
5750
+ });
5751
+ addCommonOptions(markdownCmd);
5752
+ const allCmd = addValidationSubcommand(validationCmd, subcommands.all).option("--fix", "Auto-fix ESLint issues").action(async (options) => {
4299
5753
  const result = await allCommand({
4300
5754
  cwd: process.cwd(),
4301
5755
  scope: options.scope,
@@ -4309,11 +5763,24 @@ function registerValidationCommands(validationCmd) {
4309
5763
  });
4310
5764
  addCommonOptions(allCmd);
4311
5765
  }
5766
+ function handleUnknownSubcommand(operands) {
5767
+ const [first] = operands;
5768
+ const sanitized = sanitizeCliArgument(first);
5769
+ const { domain, diagnostics } = validationCliDefinition;
5770
+ const { unknownSubcommand } = diagnostics;
5771
+ process.stderr.write(`spx ${domain.commandName}: ${unknownSubcommand.messageLabel}: ${sanitized}
5772
+ `);
5773
+ process.exit(unknownSubcommand.exitCode);
5774
+ }
4312
5775
  var validationDomain = {
4313
- name: "validation",
4314
- description: "Run code validation tools",
5776
+ name: validationCliDefinition.domain.commandName,
5777
+ description: validationCliDefinition.domain.description,
4315
5778
  register: (program2) => {
4316
- const validationCmd = program2.command("validation").alias("v").description("Run code validation tools");
5779
+ const { domain } = validationCliDefinition;
5780
+ const validationCmd = program2.command(domain.commandName).alias(domain.alias).description(domain.description);
5781
+ validationCmd.on("command:*", (operands) => {
5782
+ handleUnknownSubcommand(operands);
5783
+ });
4317
5784
  registerValidationCommands(validationCmd);
4318
5785
  }
4319
5786
  };
@@ -4323,7 +5790,9 @@ var require3 = createRequire2(import.meta.url);
4323
5790
  var { version } = require3("../package.json");
4324
5791
  var program = new Command();
4325
5792
  program.name("spx").description("Fast, deterministic CLI tool for spec workflow management").version(version);
5793
+ auditDomain.register(program);
4326
5794
  claudeDomain.register(program);
5795
+ configDomain.register(program);
4327
5796
  sessionDomain.register(program);
4328
5797
  specDomain.register(program);
4329
5798
  validationDomain.register(program);