@outcomeeng/spx 0.1.8 → 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/README.md +68 -41
- package/bin/spx.js +17 -20
- package/dist/cli.js +2112 -400
- package/dist/cli.js.map +1 -1
- package/package.json +27 -17
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: (
|
|
771
|
+
writeFile: (path9, content) => fs5.writeFile(path9, content, "utf-8"),
|
|
553
772
|
rename: fs5.rename,
|
|
554
773
|
unlink: fs5.unlink,
|
|
555
|
-
mkdir: async (
|
|
556
|
-
await fs5.mkdir(
|
|
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
|
|
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 :
|
|
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:
|
|
803
|
-
doingDir:
|
|
804
|
-
archiveDir:
|
|
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 =
|
|
1474
|
+
const baseDir = join2(gitResult.root, DEFAULT_CONFIG.sessions.dir);
|
|
810
1475
|
return {
|
|
811
1476
|
config: {
|
|
812
|
-
todoDir:
|
|
813
|
-
doingDir:
|
|
814
|
-
archiveDir:
|
|
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
|
|
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);
|
|
1606
|
+
async function fileExists(path9) {
|
|
918
1607
|
try {
|
|
919
|
-
const
|
|
920
|
-
|
|
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
|
-
}
|
|
931
|
-
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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);
|
|
944
1629
|
}
|
|
945
|
-
|
|
1630
|
+
const location = findSessionForArchive(existingPaths);
|
|
1631
|
+
if (!location) {
|
|
1632
|
+
throw new SessionNotFoundError(sessionId);
|
|
1633
|
+
}
|
|
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((
|
|
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
|
|
1662
|
+
import { join as join4 } from "path";
|
|
973
1663
|
|
|
974
1664
|
// src/session/list.ts
|
|
975
|
-
import { parse as
|
|
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 =
|
|
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:
|
|
1101
|
-
doingDir:
|
|
1102
|
-
archiveDir:
|
|
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
|
|
1829
|
+
for (const path9 of paths) {
|
|
1140
1830
|
try {
|
|
1141
|
-
const stats = await stat2(
|
|
1831
|
+
const stats = await stat2(path9);
|
|
1142
1832
|
if (stats.isFile()) {
|
|
1143
|
-
existing.push(
|
|
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
|
|
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 =
|
|
1218
|
-
const absolutePath =
|
|
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
|
|
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 =
|
|
1240
|
-
const content = await
|
|
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
|
|
1303
|
-
import { join as
|
|
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 =
|
|
1349
|
-
const content = await
|
|
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
|
|
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
|
|
1397
|
-
import { join as
|
|
1398
|
-
|
|
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 =
|
|
1423
|
-
const content = await
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
2260
|
+
const content = await readFile6(found.path, "utf-8");
|
|
1563
2261
|
return formatShowOutput(content, { status: found.status });
|
|
1564
2262
|
}
|
|
1565
|
-
async function
|
|
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((
|
|
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
|
-
|
|
2338
|
+
resolve6(data.trim() || void 0);
|
|
1641
2339
|
});
|
|
1642
2340
|
process.stdin.on("error", () => {
|
|
1643
|
-
|
|
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
|
|
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 [
|
|
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
|
-
|
|
2404
|
+
sessionIds: ids,
|
|
1707
2405
|
sessionsDir: options.sessionsDir
|
|
1708
2406
|
});
|
|
1709
2407
|
console.log(output);
|
|
@@ -1776,6 +2474,9 @@ var sessionDomain = {
|
|
|
1776
2474
|
}
|
|
1777
2475
|
};
|
|
1778
2476
|
|
|
2477
|
+
// src/domains/spec/index.ts
|
|
2478
|
+
import { access as access2, readFile as readFile7, writeFile as writeFile2 } from "fs/promises";
|
|
2479
|
+
|
|
1779
2480
|
// src/scanner/scanner.ts
|
|
1780
2481
|
import path5 from "path";
|
|
1781
2482
|
var Scanner = class {
|
|
@@ -1893,34 +2594,28 @@ var StatusDeterminationError = class extends Error {
|
|
|
1893
2594
|
};
|
|
1894
2595
|
async function getWorkItemStatus(workItemPath) {
|
|
1895
2596
|
try {
|
|
1896
|
-
try {
|
|
1897
|
-
await access(workItemPath);
|
|
1898
|
-
} catch (error) {
|
|
1899
|
-
if (error.code === "ENOENT") {
|
|
1900
|
-
throw new Error(`Work item not found: ${workItemPath}`);
|
|
1901
|
-
}
|
|
1902
|
-
throw error;
|
|
1903
|
-
}
|
|
1904
2597
|
const testsPath = path6.join(workItemPath, "tests");
|
|
1905
|
-
let
|
|
2598
|
+
let entries;
|
|
1906
2599
|
try {
|
|
1907
|
-
await
|
|
1908
|
-
hasTests = true;
|
|
2600
|
+
entries = await readdir5(testsPath);
|
|
1909
2601
|
} catch (error) {
|
|
1910
2602
|
if (error.code === "ENOENT") {
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
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
|
+
});
|
|
1914
2616
|
}
|
|
2617
|
+
throw error;
|
|
1915
2618
|
}
|
|
1916
|
-
if (!hasTests) {
|
|
1917
|
-
return determineStatus({
|
|
1918
|
-
hasTestsDir: false,
|
|
1919
|
-
hasDoneMd: false,
|
|
1920
|
-
testsIsEmpty: true
|
|
1921
|
-
});
|
|
1922
|
-
}
|
|
1923
|
-
const entries = await readdir5(testsPath);
|
|
1924
2619
|
const hasDone = entries.includes("DONE.md");
|
|
1925
2620
|
if (hasDone) {
|
|
1926
2621
|
const donePath = path6.join(testsPath, "DONE.md");
|
|
@@ -2040,6 +2735,14 @@ function rollupStatus(nodes) {
|
|
|
2040
2735
|
}
|
|
2041
2736
|
|
|
2042
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 = " > ";
|
|
2043
2746
|
function findNextWorkItem(tree) {
|
|
2044
2747
|
return findFirstNonDoneLeaf(tree.nodes);
|
|
2045
2748
|
}
|
|
@@ -2049,46 +2752,18 @@ function findFirstNonDoneLeaf(nodes) {
|
|
|
2049
2752
|
if (node.status !== "DONE") {
|
|
2050
2753
|
return node;
|
|
2051
2754
|
}
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2755
|
+
continue;
|
|
2756
|
+
}
|
|
2757
|
+
const found = findFirstNonDoneLeaf(node.children);
|
|
2758
|
+
if (found !== null) {
|
|
2759
|
+
return found;
|
|
2057
2760
|
}
|
|
2058
2761
|
}
|
|
2059
2762
|
return null;
|
|
2060
2763
|
}
|
|
2061
2764
|
function formatWorkItemName(node) {
|
|
2062
|
-
const
|
|
2063
|
-
return `${node.kind}-${
|
|
2064
|
-
}
|
|
2065
|
-
async function nextCommand(options = {}) {
|
|
2066
|
-
const cwd = options.cwd || process.cwd();
|
|
2067
|
-
const scanner = new Scanner(cwd, DEFAULT_CONFIG);
|
|
2068
|
-
const workItems = await scanner.scan();
|
|
2069
|
-
if (workItems.length === 0) {
|
|
2070
|
-
return `No work items found in ${DEFAULT_CONFIG.specs.root}/${DEFAULT_CONFIG.specs.work.dir}/${DEFAULT_CONFIG.specs.work.statusDirs.doing}`;
|
|
2071
|
-
}
|
|
2072
|
-
const tree = await buildTree(workItems);
|
|
2073
|
-
const next = findNextWorkItem(tree);
|
|
2074
|
-
if (!next) {
|
|
2075
|
-
return "All work items are complete! \u{1F389}";
|
|
2076
|
-
}
|
|
2077
|
-
const parents = findParents(tree.nodes, next);
|
|
2078
|
-
const lines = [];
|
|
2079
|
-
lines.push("Next work item:");
|
|
2080
|
-
lines.push("");
|
|
2081
|
-
if (parents.capability && parents.feature) {
|
|
2082
|
-
lines.push(
|
|
2083
|
-
` ${formatWorkItemName(parents.capability)} > ${formatWorkItemName(parents.feature)} > ${formatWorkItemName(next)}`
|
|
2084
|
-
);
|
|
2085
|
-
} else {
|
|
2086
|
-
lines.push(` ${formatWorkItemName(next)}`);
|
|
2087
|
-
}
|
|
2088
|
-
lines.push("");
|
|
2089
|
-
lines.push(` Status: ${next.status}`);
|
|
2090
|
-
lines.push(` Path: ${next.path}`);
|
|
2091
|
-
return lines.join("\n");
|
|
2765
|
+
const displayNumber = node.kind === "capability" ? node.number + 1 : node.number;
|
|
2766
|
+
return `${node.kind}-${displayNumber}_${node.slug}`;
|
|
2092
2767
|
}
|
|
2093
2768
|
function findParents(nodes, target) {
|
|
2094
2769
|
for (const capability of nodes) {
|
|
@@ -2102,9 +2777,32 @@ function findParents(nodes, target) {
|
|
|
2102
2777
|
}
|
|
2103
2778
|
return {};
|
|
2104
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
|
+
}
|
|
2105
2803
|
|
|
2106
2804
|
// src/reporter/json.ts
|
|
2107
|
-
var
|
|
2805
|
+
var JSON_INDENT3 = 2;
|
|
2108
2806
|
function formatJSON(tree, config) {
|
|
2109
2807
|
const capabilities = tree.nodes.map((node) => nodeToJSON(node));
|
|
2110
2808
|
const summary = calculateSummary(tree);
|
|
@@ -2116,7 +2814,7 @@ function formatJSON(tree, config) {
|
|
|
2116
2814
|
summary,
|
|
2117
2815
|
capabilities
|
|
2118
2816
|
};
|
|
2119
|
-
return JSON.stringify(output, null,
|
|
2817
|
+
return JSON.stringify(output, null, JSON_INDENT3);
|
|
2120
2818
|
}
|
|
2121
2819
|
function nodeToJSON(node) {
|
|
2122
2820
|
const displayNumber = getDisplayNumber(node);
|
|
@@ -2298,26 +2996,32 @@ function formatStatus(status) {
|
|
|
2298
2996
|
}
|
|
2299
2997
|
|
|
2300
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
|
+
}
|
|
2301
3010
|
async function statusCommand(options = {}) {
|
|
2302
|
-
const cwd = options.cwd
|
|
2303
|
-
const format2 = options.format
|
|
3011
|
+
const cwd = options.cwd ?? process.cwd();
|
|
3012
|
+
const format2 = options.format ?? DEFAULT_FORMAT;
|
|
2304
3013
|
const scanner = new Scanner(cwd, DEFAULT_CONFIG);
|
|
2305
3014
|
let workItems;
|
|
2306
3015
|
try {
|
|
2307
3016
|
workItems = await scanner.scan();
|
|
2308
3017
|
} catch (error) {
|
|
2309
3018
|
if (error instanceof Error && error.message.includes("ENOENT")) {
|
|
2310
|
-
|
|
2311
|
-
throw new Error(
|
|
2312
|
-
`Directory ${doingPath} not found.
|
|
2313
|
-
|
|
2314
|
-
This command is for legacy specs/ projects. For CODE framework projects, check the spx/ directory for specifications.`
|
|
2315
|
-
);
|
|
3019
|
+
throw new Error(buildMissingDirectoryMessage());
|
|
2316
3020
|
}
|
|
2317
3021
|
throw error;
|
|
2318
3022
|
}
|
|
2319
3023
|
if (workItems.length === 0) {
|
|
2320
|
-
return
|
|
3024
|
+
return "No work items found in specs/work/doing";
|
|
2321
3025
|
}
|
|
2322
3026
|
const tree = await buildTree(workItems);
|
|
2323
3027
|
switch (format2) {
|
|
@@ -2328,60 +3032,762 @@ This command is for legacy specs/ projects. For CODE framework projects, check t
|
|
|
2328
3032
|
case "table":
|
|
2329
3033
|
return formatTable(tree);
|
|
2330
3034
|
case "text":
|
|
2331
|
-
default:
|
|
2332
3035
|
return formatText(tree);
|
|
2333
3036
|
}
|
|
2334
3037
|
}
|
|
2335
3038
|
|
|
2336
|
-
// src/
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
3039
|
+
// src/spec/apply/exclude/adapters/detect.ts
|
|
3040
|
+
import { join as join9 } from "path";
|
|
3041
|
+
|
|
3042
|
+
// src/spec/apply/exclude/constants.ts
|
|
3043
|
+
var SPX_PREFIX = "spx/";
|
|
3044
|
+
var NODE_SUFFIXES2 = [".outcome/", ".enabler/", ".capability/", ".feature/", ".story/"];
|
|
3045
|
+
var COMMENT_CHAR = "#";
|
|
3046
|
+
var EXCLUDE_FILENAME = "EXCLUDE";
|
|
3047
|
+
|
|
3048
|
+
// src/spec/apply/exclude/mappings.ts
|
|
3049
|
+
function toPytestIgnore(node) {
|
|
3050
|
+
return `--ignore=${SPX_PREFIX}${node}/`;
|
|
3051
|
+
}
|
|
3052
|
+
function escapeRegex(str) {
|
|
3053
|
+
return str.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
|
3054
|
+
}
|
|
3055
|
+
function toMypyRegex(node) {
|
|
3056
|
+
const escaped = escapeRegex(`${SPX_PREFIX}${node}/`);
|
|
3057
|
+
return `^${escaped}`;
|
|
3058
|
+
}
|
|
3059
|
+
function toPyrightPath(node) {
|
|
3060
|
+
return `${SPX_PREFIX}${node}/`;
|
|
3061
|
+
}
|
|
3062
|
+
function isExcludedEntry(val) {
|
|
3063
|
+
const hasPrefix = val.includes(SPX_PREFIX);
|
|
3064
|
+
const hasSuffix = NODE_SUFFIXES2.some((suffix) => val.includes(suffix));
|
|
3065
|
+
return hasPrefix && hasSuffix;
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
// src/spec/apply/exclude/adapters/python.ts
|
|
3069
|
+
var PYTHON_CONFIG_FILE = "pyproject.toml";
|
|
3070
|
+
var PYTEST_SECTION = "tool.pytest.ini_options";
|
|
3071
|
+
var MYPY_SECTION = "tool.mypy";
|
|
3072
|
+
var PYRIGHT_SECTION = "tool.pyright";
|
|
3073
|
+
var ARRAY_INDENT = " ";
|
|
3074
|
+
function updatePytestAddopts(content, nodes) {
|
|
3075
|
+
const sectionPattern = new RegExp(`^\\[${PYTEST_SECTION.replace(/\./g, "\\.")}\\]`, "m");
|
|
3076
|
+
const sectionMatch = sectionPattern.exec(content);
|
|
3077
|
+
if (!sectionMatch) return content;
|
|
3078
|
+
const afterSection = content.slice(sectionMatch.index);
|
|
3079
|
+
const addoptsPattern = /^([ \t]*addopts\s*=\s*")((?:[^"\\]|\\.)*)(")/m;
|
|
3080
|
+
const addoptsMatch = addoptsPattern.exec(afterSection);
|
|
3081
|
+
if (!addoptsMatch) return content;
|
|
3082
|
+
const prefix = addoptsMatch[1];
|
|
3083
|
+
const currentValue = addoptsMatch[2];
|
|
3084
|
+
const suffix = addoptsMatch[3];
|
|
3085
|
+
const parts = currentValue.split(/\s+/).filter((p) => p.length > 0);
|
|
3086
|
+
const kept = parts.filter((p) => !isExcludedEntry(p));
|
|
3087
|
+
const newIgnores = nodes.map(toPytestIgnore);
|
|
3088
|
+
const updatedValue = [...kept, ...newIgnores].join(" ");
|
|
3089
|
+
const absoluteStart = sectionMatch.index + addoptsMatch.index;
|
|
3090
|
+
const absoluteEnd = absoluteStart + addoptsMatch[0].length;
|
|
3091
|
+
return content.slice(0, absoluteStart) + prefix + updatedValue + suffix + content.slice(absoluteEnd);
|
|
3092
|
+
}
|
|
3093
|
+
function findTomlArray(content, sectionHeader, key) {
|
|
3094
|
+
const sectionPattern = new RegExp(`^\\[${sectionHeader.replace(/\./g, "\\.")}\\]`, "m");
|
|
3095
|
+
const sectionMatch = sectionPattern.exec(content);
|
|
3096
|
+
if (!sectionMatch) return null;
|
|
3097
|
+
const afterSection = content.slice(sectionMatch.index);
|
|
3098
|
+
const nextSectionMatch = /^\[(?!.*\].*=)/m.exec(afterSection.slice(sectionMatch[0].length));
|
|
3099
|
+
const sectionEnd = nextSectionMatch ? sectionMatch.index + sectionMatch[0].length + nextSectionMatch.index : content.length;
|
|
3100
|
+
const keyPattern = new RegExp(`^([ \\t]*${key}\\s*=\\s*)\\[`, "m");
|
|
3101
|
+
const regionToSearch = content.slice(sectionMatch.index, sectionEnd);
|
|
3102
|
+
const keyMatch = keyPattern.exec(regionToSearch);
|
|
3103
|
+
if (!keyMatch) return null;
|
|
3104
|
+
const arrayStart = sectionMatch.index + keyMatch.index + keyMatch[1].length;
|
|
3105
|
+
let depth = 0;
|
|
3106
|
+
let arrayEnd = arrayStart;
|
|
3107
|
+
for (let i = arrayStart; i < content.length; i++) {
|
|
3108
|
+
if (content[i] === "[") depth++;
|
|
3109
|
+
if (content[i] === "]") {
|
|
3110
|
+
depth--;
|
|
3111
|
+
if (depth === 0) {
|
|
3112
|
+
arrayEnd = i + 1;
|
|
3113
|
+
break;
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
return { arrayStart, arrayEnd };
|
|
3118
|
+
}
|
|
3119
|
+
function updateTomlArray(content, sectionHeader, key, newEntries) {
|
|
3120
|
+
const info = findTomlArray(content, sectionHeader, key);
|
|
3121
|
+
if (!info) return content;
|
|
3122
|
+
const arrayContent = content.slice(info.arrayStart + 1, info.arrayEnd - 1);
|
|
3123
|
+
const lines = arrayContent.split("\n");
|
|
3124
|
+
const keptLines = [];
|
|
3125
|
+
for (const line of lines) {
|
|
3126
|
+
const trimmed = line.trim();
|
|
3127
|
+
if (trimmed === "" || trimmed.startsWith("#")) {
|
|
3128
|
+
keptLines.push(line);
|
|
3129
|
+
continue;
|
|
3130
|
+
}
|
|
3131
|
+
const stringMatch = /"((?:[^"\\]|\\.)*)"/.exec(trimmed);
|
|
3132
|
+
if (stringMatch && isExcludedEntry(stringMatch[1])) {
|
|
3133
|
+
continue;
|
|
3134
|
+
}
|
|
3135
|
+
keptLines.push(line);
|
|
3136
|
+
}
|
|
3137
|
+
const newLines = newEntries.map((entry) => `${ARRAY_INDENT}"${entry}",`);
|
|
3138
|
+
while (keptLines.length > 0 && keptLines[keptLines.length - 1].trim() === "") {
|
|
3139
|
+
keptLines.pop();
|
|
3140
|
+
}
|
|
3141
|
+
const reconstructed = [...keptLines, ...newLines, ""].join("\n");
|
|
3142
|
+
return content.slice(0, info.arrayStart + 1) + reconstructed + content.slice(info.arrayEnd - 1);
|
|
3143
|
+
}
|
|
3144
|
+
var pythonAdapter = {
|
|
3145
|
+
language: "Python",
|
|
3146
|
+
configFile: PYTHON_CONFIG_FILE,
|
|
3147
|
+
tools: ["pytest", "mypy", "pyright"],
|
|
3148
|
+
excluded: ["ruff (style checked regardless of implementation existence)"],
|
|
3149
|
+
applyExclusions(content, nodes) {
|
|
3150
|
+
let result = content;
|
|
3151
|
+
result = updatePytestAddopts(result, nodes);
|
|
3152
|
+
const mypyEntries = nodes.map(toMypyRegex);
|
|
3153
|
+
result = updateTomlArray(result, MYPY_SECTION, "exclude", mypyEntries);
|
|
3154
|
+
const pyrightEntries = nodes.map(toPyrightPath);
|
|
3155
|
+
result = updateTomlArray(result, PYRIGHT_SECTION, "exclude", pyrightEntries);
|
|
3156
|
+
return {
|
|
3157
|
+
changed: result !== content,
|
|
3158
|
+
content: result
|
|
3159
|
+
};
|
|
3160
|
+
}
|
|
3161
|
+
};
|
|
3162
|
+
|
|
3163
|
+
// src/spec/apply/exclude/adapters/detect.ts
|
|
3164
|
+
var ADAPTERS = [pythonAdapter];
|
|
3165
|
+
async function detectLanguage(projectRoot, deps) {
|
|
3166
|
+
for (const adapter of ADAPTERS) {
|
|
3167
|
+
const configPath = join9(projectRoot, adapter.configFile);
|
|
3168
|
+
const exists = await deps.fileExists(configPath);
|
|
3169
|
+
if (exists) {
|
|
3170
|
+
return adapter;
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
return null;
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
// src/spec/apply/exclude/help.ts
|
|
3177
|
+
function buildApplyHelp() {
|
|
3178
|
+
const excludePath = `${SPX_PREFIX}${EXCLUDE_FILENAME}`;
|
|
3179
|
+
const languageLines = ADAPTERS.map((adapter) => {
|
|
3180
|
+
const tools = adapter.tools.join(", ");
|
|
3181
|
+
const excluded = adapter.excluded.length > 0 ? `
|
|
3182
|
+
NOT configured: ${adapter.excluded.join(", ")}` : "";
|
|
3183
|
+
return ` ${adapter.language} (${adapter.configFile})
|
|
3184
|
+
Configures: ${tools}${excluded}`;
|
|
3185
|
+
});
|
|
3186
|
+
return `
|
|
3187
|
+
Reads ${excludePath} and applies exclusions to the project's tool configuration.
|
|
3188
|
+
Each excluded node is translated into the appropriate format for the detected
|
|
3189
|
+
language's test runner, type checker, and other quality gate tools.
|
|
3190
|
+
|
|
3191
|
+
Source:
|
|
3192
|
+
${excludePath}
|
|
3193
|
+
One node path per line. Comments (#) and blank lines are ignored.
|
|
3194
|
+
Path traversal (..) and absolute paths are rejected.
|
|
3195
|
+
|
|
3196
|
+
Supported languages:
|
|
3197
|
+
${languageLines.join("\n\n")}
|
|
3198
|
+
|
|
3199
|
+
Detection:
|
|
3200
|
+
The language is auto-detected by checking for config files in order.
|
|
3201
|
+
The first match wins.
|
|
3202
|
+
|
|
3203
|
+
Examples:
|
|
3204
|
+
spx spec apply # Apply exclusions to detected config file
|
|
3205
|
+
`;
|
|
3206
|
+
}
|
|
3207
|
+
var APPLY_HELP = buildApplyHelp();
|
|
3208
|
+
|
|
3209
|
+
// src/spec/apply/exclude/exclude-file.ts
|
|
3210
|
+
var TOML_UNSAFE_PATTERN = /["\\\n\r\t]/;
|
|
3211
|
+
var PATH_TRAVERSAL_PATTERN = /(?:^|\/)\.\.(?:\/|$)/;
|
|
3212
|
+
function validateNodePath(path9) {
|
|
3213
|
+
if (path9.startsWith("/")) {
|
|
3214
|
+
return `absolute path rejected: ${path9}`;
|
|
3215
|
+
}
|
|
3216
|
+
if (PATH_TRAVERSAL_PATTERN.test(path9)) {
|
|
3217
|
+
return `path traversal rejected: ${path9}`;
|
|
3218
|
+
}
|
|
3219
|
+
if (TOML_UNSAFE_PATTERN.test(path9)) {
|
|
3220
|
+
return `TOML-unsafe characters rejected: ${path9}`;
|
|
3221
|
+
}
|
|
3222
|
+
return null;
|
|
3223
|
+
}
|
|
3224
|
+
function readExcludedNodes(content) {
|
|
3225
|
+
return content.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith(COMMENT_CHAR)).filter((line) => validateNodePath(line) === null);
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
// src/spec/apply/exclude/command.ts
|
|
3229
|
+
import { join as join10 } from "path";
|
|
3230
|
+
async function applyExcludeCommand(options) {
|
|
3231
|
+
const { cwd, deps } = options;
|
|
3232
|
+
const excludePath = join10(cwd, SPX_PREFIX, EXCLUDE_FILENAME);
|
|
3233
|
+
const excludeExists = await deps.fileExists(excludePath);
|
|
3234
|
+
if (!excludeExists) {
|
|
3235
|
+
return { exitCode: 1, output: `error: ${excludePath} not found` };
|
|
3236
|
+
}
|
|
3237
|
+
const adapter = await detectLanguage(cwd, deps);
|
|
3238
|
+
if (!adapter) {
|
|
3239
|
+
return { exitCode: 1, output: "error: no supported config file found (checked: pyproject.toml)" };
|
|
3240
|
+
}
|
|
3241
|
+
const configPath = join10(cwd, adapter.configFile);
|
|
3242
|
+
const excludeContent = await deps.readFile(excludePath);
|
|
3243
|
+
const configContent = await deps.readFile(configPath);
|
|
3244
|
+
const nodes = readExcludedNodes(excludeContent);
|
|
3245
|
+
if (nodes.length === 0) {
|
|
3246
|
+
return { exitCode: 0, output: "spx/EXCLUDE is empty \u2014 no excluded nodes to apply." };
|
|
3247
|
+
}
|
|
3248
|
+
const result = adapter.applyExclusions(configContent, nodes);
|
|
3249
|
+
if (result.changed) {
|
|
3250
|
+
await deps.writeFile(configPath, result.content);
|
|
3251
|
+
return {
|
|
3252
|
+
exitCode: 0,
|
|
3253
|
+
output: `Updated ${adapter.configFile} from spx/EXCLUDE (${nodes.length} nodes).`
|
|
3254
|
+
};
|
|
3255
|
+
}
|
|
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
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
|
|
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;
|
|
2353
3661
|
}
|
|
2354
|
-
const output = await statusCommand({ cwd: process.cwd(), format: format2 });
|
|
2355
|
-
console.log(output);
|
|
2356
|
-
} catch (error) {
|
|
2357
|
-
console.error(
|
|
2358
|
-
"Error:",
|
|
2359
|
-
error instanceof Error ? error.message : String(error)
|
|
2360
|
-
);
|
|
2361
|
-
process.exit(1);
|
|
2362
3662
|
}
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
const output = await nextCommand({ cwd: process.cwd() });
|
|
2367
|
-
console.log(output);
|
|
2368
|
-
} catch (error) {
|
|
2369
|
-
console.error(
|
|
2370
|
-
"Error:",
|
|
2371
|
-
error instanceof Error ? error.message : String(error)
|
|
2372
|
-
);
|
|
2373
|
-
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]) };
|
|
2374
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
|
|
2375
3691
|
});
|
|
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
|
+
}
|
|
2376
3748
|
}
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
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;
|
|
2383
3770
|
}
|
|
2384
|
-
|
|
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
|
+
}
|
|
2385
3791
|
|
|
2386
3792
|
// node_modules/.pnpm/jsonc-parser@3.3.1/node_modules/jsonc-parser/lib/esm/impl/scanner.js
|
|
2387
3793
|
function createScanner(text, ignoreTrivia = false) {
|
|
@@ -3243,15 +4649,15 @@ var ParseErrorCode;
|
|
|
3243
4649
|
})(ParseErrorCode || (ParseErrorCode = {}));
|
|
3244
4650
|
|
|
3245
4651
|
// src/validation/config/scope.ts
|
|
3246
|
-
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
3247
|
-
import { join as
|
|
4652
|
+
import { existsSync as existsSync2, readdirSync, readFileSync } from "fs";
|
|
4653
|
+
import { join as join14 } from "path";
|
|
3248
4654
|
var TSCONFIG_FILES = {
|
|
3249
4655
|
full: "tsconfig.json",
|
|
3250
4656
|
production: "tsconfig.production.json"
|
|
3251
4657
|
};
|
|
3252
4658
|
var defaultScopeDeps = {
|
|
3253
4659
|
readFileSync,
|
|
3254
|
-
existsSync,
|
|
4660
|
+
existsSync: existsSync2,
|
|
3255
4661
|
readdirSync
|
|
3256
4662
|
};
|
|
3257
4663
|
function parseTypeScriptConfig(configPath, deps = defaultScopeDeps) {
|
|
@@ -3291,7 +4697,7 @@ function hasTypeScriptFilesRecursive(dirPath, maxDepth = 2, deps = defaultScopeD
|
|
|
3291
4697
|
if (hasDirectTsFiles) return true;
|
|
3292
4698
|
const subdirs = items.filter((item) => item.isDirectory() && !item.name.startsWith("."));
|
|
3293
4699
|
for (const subdir of subdirs.slice(0, 5)) {
|
|
3294
|
-
if (hasTypeScriptFilesRecursive(
|
|
4700
|
+
if (hasTypeScriptFilesRecursive(join14(dirPath, subdir.name), maxDepth - 1, deps)) {
|
|
3295
4701
|
return true;
|
|
3296
4702
|
}
|
|
3297
4703
|
}
|
|
@@ -3352,7 +4758,7 @@ function getTypeScriptScope(scope, deps = defaultScopeDeps) {
|
|
|
3352
4758
|
}
|
|
3353
4759
|
|
|
3354
4760
|
// src/validation/discovery/tool-finder.ts
|
|
3355
|
-
import { execSync } from "child_process";
|
|
4761
|
+
import { execSync as execSync2 } from "child_process";
|
|
3356
4762
|
import fs6 from "fs";
|
|
3357
4763
|
import { createRequire } from "module";
|
|
3358
4764
|
import path7 from "path";
|
|
@@ -3400,7 +4806,7 @@ var defaultToolDiscoveryDeps = {
|
|
|
3400
4806
|
existsSync: fs6.existsSync,
|
|
3401
4807
|
whichSync: (tool) => {
|
|
3402
4808
|
try {
|
|
3403
|
-
const result =
|
|
4809
|
+
const result = execSync2(`which ${tool}`, {
|
|
3404
4810
|
encoding: "utf-8",
|
|
3405
4811
|
stdio: ["pipe", "pipe", "pipe"]
|
|
3406
4812
|
});
|
|
@@ -3460,44 +4866,32 @@ function formatSkipMessage(stepName, result) {
|
|
|
3460
4866
|
return TOOL_DISCOVERY.MESSAGES.SKIP_FORMAT(stepName, result.notFound.tool);
|
|
3461
4867
|
}
|
|
3462
4868
|
|
|
3463
|
-
// src/validation/
|
|
3464
|
-
import
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
var
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
var
|
|
3474
|
-
|
|
3475
|
-
ESLINT: "ESLint",
|
|
3476
|
-
TYPESCRIPT: "TypeScript",
|
|
3477
|
-
KNIP: "Unused Code"
|
|
3478
|
-
};
|
|
3479
|
-
var STEP_DESCRIPTIONS = {
|
|
3480
|
-
CIRCULAR: "Checking for circular dependencies",
|
|
3481
|
-
ESLINT: "Validating ESLint compliance",
|
|
3482
|
-
TYPESCRIPT: "Validating TypeScript",
|
|
3483
|
-
KNIP: "Detecting unused exports, dependencies, and files"
|
|
3484
|
-
};
|
|
3485
|
-
var CACHE_PATHS = {
|
|
3486
|
-
ESLINT: "dist/.eslintcache",
|
|
3487
|
-
TIMINGS: "dist/.validation-timings.json"
|
|
3488
|
-
};
|
|
3489
|
-
var VALIDATION_KEYS = {
|
|
3490
|
-
TYPESCRIPT: "TYPESCRIPT",
|
|
3491
|
-
ESLINT: "ESLINT",
|
|
3492
|
-
KNIP: "KNIP"
|
|
3493
|
-
};
|
|
3494
|
-
var VALIDATION_DEFAULTS = {
|
|
3495
|
-
[VALIDATION_KEYS.TYPESCRIPT]: true,
|
|
3496
|
-
[VALIDATION_KEYS.ESLINT]: true,
|
|
3497
|
-
[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
|
|
3498
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
|
+
}
|
|
3499
4892
|
|
|
3500
4893
|
// src/validation/steps/circular.ts
|
|
4894
|
+
import madge from "madge";
|
|
3501
4895
|
var defaultCircularDeps = {
|
|
3502
4896
|
madge
|
|
3503
4897
|
};
|
|
@@ -3533,34 +4927,20 @@ async function validateCircularDependencies(scope, typescriptScope, deps = defau
|
|
|
3533
4927
|
return { success: false, error: errorMessage };
|
|
3534
4928
|
}
|
|
3535
4929
|
}
|
|
3536
|
-
var circularDependencyStep = {
|
|
3537
|
-
id: STEP_IDS.CIRCULAR,
|
|
3538
|
-
name: STEP_NAMES.CIRCULAR,
|
|
3539
|
-
description: STEP_DESCRIPTIONS.CIRCULAR,
|
|
3540
|
-
enabled: (context) => !context.isFileSpecificMode && context.enabledValidations[VALIDATION_KEYS.TYPESCRIPT] === true,
|
|
3541
|
-
execute: async (context) => {
|
|
3542
|
-
const startTime = performance.now();
|
|
3543
|
-
try {
|
|
3544
|
-
const result = await validateCircularDependencies(context.scope, context.scopeConfig);
|
|
3545
|
-
return {
|
|
3546
|
-
success: result.success,
|
|
3547
|
-
error: result.error,
|
|
3548
|
-
duration: performance.now() - startTime
|
|
3549
|
-
};
|
|
3550
|
-
} catch (error) {
|
|
3551
|
-
return {
|
|
3552
|
-
success: false,
|
|
3553
|
-
error: error instanceof Error ? error.message : String(error),
|
|
3554
|
-
duration: performance.now() - startTime
|
|
3555
|
-
};
|
|
3556
|
-
}
|
|
3557
|
-
}
|
|
3558
|
-
};
|
|
3559
4930
|
|
|
3560
4931
|
// src/commands/validation/circular.ts
|
|
4932
|
+
var TYPESCRIPT_ABSENT_MESSAGE = "\u23ED Skipping Circular dependencies (TypeScript not detected in project)";
|
|
3561
4933
|
async function circularCommand(options) {
|
|
3562
4934
|
const { cwd, quiet } = options;
|
|
3563
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
|
+
}
|
|
3564
4944
|
const toolResult = await discoverTool("madge", { projectRoot: cwd });
|
|
3565
4945
|
if (!toolResult.found) {
|
|
3566
4946
|
const skipMessage = formatSkipMessage("circular dependency check", toolResult);
|
|
@@ -3621,20 +5001,27 @@ var EXECUTION_MODES = {
|
|
|
3621
5001
|
WRITE: "write"
|
|
3622
5002
|
};
|
|
3623
5003
|
|
|
5004
|
+
// src/validation/steps/constants.ts
|
|
5005
|
+
var CACHE_PATHS = {
|
|
5006
|
+
ESLINT: "dist/.eslintcache",
|
|
5007
|
+
TIMINGS: "dist/.validation-timings.json"
|
|
5008
|
+
};
|
|
5009
|
+
|
|
3624
5010
|
// src/validation/steps/eslint.ts
|
|
3625
5011
|
var defaultEslintProcessRunner = { spawn };
|
|
5012
|
+
var DEFAULT_ESLINT_CONFIG_FILE = "eslint.config.ts";
|
|
3626
5013
|
function buildEslintArgs(context) {
|
|
3627
|
-
const { validatedFiles, mode, cacheFile } = context;
|
|
5014
|
+
const { validatedFiles, mode, cacheFile, configFile = DEFAULT_ESLINT_CONFIG_FILE } = context;
|
|
3628
5015
|
const fixArg = mode === EXECUTION_MODES.WRITE ? ["--fix"] : [];
|
|
3629
5016
|
const cacheArgs = ["--cache", "--cache-location", cacheFile];
|
|
3630
5017
|
if (validatedFiles && validatedFiles.length > 0) {
|
|
3631
|
-
return ["eslint", "--config",
|
|
5018
|
+
return ["eslint", "--config", configFile, ...cacheArgs, ...fixArg, "--", ...validatedFiles];
|
|
3632
5019
|
}
|
|
3633
|
-
return ["eslint", ".", "--config",
|
|
5020
|
+
return ["eslint", ".", "--config", configFile, ...cacheArgs, ...fixArg];
|
|
3634
5021
|
}
|
|
3635
5022
|
async function validateESLint(context, runner = defaultEslintProcessRunner) {
|
|
3636
|
-
const { scope, validatedFiles, mode } = context;
|
|
3637
|
-
return new Promise((
|
|
5023
|
+
const { projectRoot, scope, validatedFiles, mode, eslintConfigFile } = context;
|
|
5024
|
+
return new Promise((resolve6) => {
|
|
3638
5025
|
if (!validatedFiles || validatedFiles.length === 0) {
|
|
3639
5026
|
if (scope === VALIDATION_SCOPES.PRODUCTION) {
|
|
3640
5027
|
process.env.ESLINT_PRODUCTION_ONLY = "1";
|
|
@@ -3645,57 +5032,35 @@ async function validateESLint(context, runner = defaultEslintProcessRunner) {
|
|
|
3645
5032
|
const eslintArgs = buildEslintArgs({
|
|
3646
5033
|
validatedFiles,
|
|
3647
5034
|
mode,
|
|
3648
|
-
cacheFile: CACHE_PATHS.ESLINT
|
|
5035
|
+
cacheFile: CACHE_PATHS.ESLINT,
|
|
5036
|
+
configFile: eslintConfigFile
|
|
3649
5037
|
});
|
|
3650
5038
|
const eslintProcess = runner.spawn("npx", eslintArgs, {
|
|
3651
|
-
cwd:
|
|
5039
|
+
cwd: projectRoot,
|
|
3652
5040
|
stdio: "inherit"
|
|
3653
5041
|
});
|
|
3654
5042
|
eslintProcess.on("close", (code) => {
|
|
3655
5043
|
if (code === 0) {
|
|
3656
|
-
|
|
5044
|
+
resolve6({ success: true });
|
|
3657
5045
|
} else {
|
|
3658
|
-
|
|
5046
|
+
resolve6({ success: false, error: `ESLint exited with code ${code}` });
|
|
3659
5047
|
}
|
|
3660
5048
|
});
|
|
3661
5049
|
eslintProcess.on("error", (error) => {
|
|
3662
|
-
|
|
5050
|
+
resolve6({ success: false, error: error.message });
|
|
3663
5051
|
});
|
|
3664
5052
|
});
|
|
3665
5053
|
}
|
|
3666
|
-
function validationEnabled(envVarKey,
|
|
5054
|
+
function validationEnabled(envVarKey, defaults2 = {}) {
|
|
3667
5055
|
const envVar = `${envVarKey}_VALIDATION_ENABLED`;
|
|
3668
5056
|
const explicitlyDisabled = process.env[envVar] === "0";
|
|
3669
5057
|
const explicitlyEnabled = process.env[envVar] === "1";
|
|
3670
|
-
const defaultValue =
|
|
5058
|
+
const defaultValue = defaults2[envVarKey] ?? true;
|
|
3671
5059
|
if (defaultValue) {
|
|
3672
5060
|
return !explicitlyDisabled;
|
|
3673
5061
|
}
|
|
3674
5062
|
return explicitlyEnabled;
|
|
3675
5063
|
}
|
|
3676
|
-
var eslintStep = {
|
|
3677
|
-
id: STEP_IDS.ESLINT,
|
|
3678
|
-
name: STEP_NAMES.ESLINT,
|
|
3679
|
-
description: STEP_DESCRIPTIONS.ESLINT,
|
|
3680
|
-
enabled: (context) => context.enabledValidations[VALIDATION_KEYS.ESLINT] === true && validationEnabled(VALIDATION_KEYS.ESLINT),
|
|
3681
|
-
execute: async (context) => {
|
|
3682
|
-
const startTime = performance.now();
|
|
3683
|
-
try {
|
|
3684
|
-
const result = await validateESLint(context);
|
|
3685
|
-
return {
|
|
3686
|
-
success: result.success,
|
|
3687
|
-
error: result.error,
|
|
3688
|
-
duration: performance.now() - startTime
|
|
3689
|
-
};
|
|
3690
|
-
} catch (error) {
|
|
3691
|
-
return {
|
|
3692
|
-
success: false,
|
|
3693
|
-
error: error instanceof Error ? error.message : String(error),
|
|
3694
|
-
duration: performance.now() - startTime
|
|
3695
|
-
};
|
|
3696
|
-
}
|
|
3697
|
-
}
|
|
3698
|
-
};
|
|
3699
5064
|
|
|
3700
5065
|
// src/validation/steps/knip.ts
|
|
3701
5066
|
import { spawn as spawn2 } from "child_process";
|
|
@@ -3706,7 +5071,7 @@ async function validateKnip(typescriptScope, runner = defaultKnipProcessRunner)
|
|
|
3706
5071
|
if (analyzeDirectories.length === 0) {
|
|
3707
5072
|
return { success: true };
|
|
3708
5073
|
}
|
|
3709
|
-
return new Promise((
|
|
5074
|
+
return new Promise((resolve6) => {
|
|
3710
5075
|
const knipProcess = runner.spawn("npx", ["knip"], {
|
|
3711
5076
|
cwd: process.cwd(),
|
|
3712
5077
|
stdio: "pipe"
|
|
@@ -3721,17 +5086,17 @@ async function validateKnip(typescriptScope, runner = defaultKnipProcessRunner)
|
|
|
3721
5086
|
});
|
|
3722
5087
|
knipProcess.on("close", (code) => {
|
|
3723
5088
|
if (code === 0) {
|
|
3724
|
-
|
|
5089
|
+
resolve6({ success: true });
|
|
3725
5090
|
} else {
|
|
3726
5091
|
const errorOutput = knipOutput || knipError || "Unused code detected";
|
|
3727
|
-
|
|
5092
|
+
resolve6({
|
|
3728
5093
|
success: false,
|
|
3729
5094
|
error: errorOutput
|
|
3730
5095
|
});
|
|
3731
5096
|
}
|
|
3732
5097
|
});
|
|
3733
5098
|
knipProcess.on("error", (error) => {
|
|
3734
|
-
|
|
5099
|
+
resolve6({ success: false, error: error.message });
|
|
3735
5100
|
});
|
|
3736
5101
|
});
|
|
3737
5102
|
} catch (error) {
|
|
@@ -3739,29 +5104,6 @@ async function validateKnip(typescriptScope, runner = defaultKnipProcessRunner)
|
|
|
3739
5104
|
return { success: false, error: errorMessage };
|
|
3740
5105
|
}
|
|
3741
5106
|
}
|
|
3742
|
-
var knipStep = {
|
|
3743
|
-
id: STEP_IDS.KNIP,
|
|
3744
|
-
name: STEP_NAMES.KNIP,
|
|
3745
|
-
description: STEP_DESCRIPTIONS.KNIP,
|
|
3746
|
-
enabled: (context) => context.enabledValidations[VALIDATION_KEYS.KNIP] === true && validationEnabled(VALIDATION_KEYS.KNIP, VALIDATION_DEFAULTS) && !context.isFileSpecificMode,
|
|
3747
|
-
execute: async (context) => {
|
|
3748
|
-
const startTime = performance.now();
|
|
3749
|
-
try {
|
|
3750
|
-
const result = await validateKnip(context.scopeConfig);
|
|
3751
|
-
return {
|
|
3752
|
-
success: result.success,
|
|
3753
|
-
error: result.error,
|
|
3754
|
-
duration: performance.now() - startTime
|
|
3755
|
-
};
|
|
3756
|
-
} catch (error) {
|
|
3757
|
-
return {
|
|
3758
|
-
success: false,
|
|
3759
|
-
error: error instanceof Error ? error.message : String(error),
|
|
3760
|
-
duration: performance.now() - startTime
|
|
3761
|
-
};
|
|
3762
|
-
}
|
|
3763
|
-
}
|
|
3764
|
-
};
|
|
3765
5107
|
|
|
3766
5108
|
// src/commands/validation/knip.ts
|
|
3767
5109
|
async function knipCommand(options) {
|
|
@@ -3789,9 +5131,26 @@ async function knipCommand(options) {
|
|
|
3789
5131
|
}
|
|
3790
5132
|
|
|
3791
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}";
|
|
3792
5136
|
async function lintCommand(options) {
|
|
3793
5137
|
const { cwd, scope = "full", files, fix, quiet } = options;
|
|
3794
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
|
+
}
|
|
3795
5154
|
const toolResult = await discoverTool("eslint", { projectRoot: cwd });
|
|
3796
5155
|
if (!toolResult.found) {
|
|
3797
5156
|
const skipMessage = formatSkipMessage("ESLint", toolResult);
|
|
@@ -3805,7 +5164,8 @@ async function lintCommand(options) {
|
|
|
3805
5164
|
mode: fix ? "write" : "read",
|
|
3806
5165
|
enabledValidations: { ESLINT: true },
|
|
3807
5166
|
validatedFiles: files,
|
|
3808
|
-
isFileSpecificMode: Boolean(files && files.length > 0)
|
|
5167
|
+
isFileSpecificMode: Boolean(files && files.length > 0),
|
|
5168
|
+
eslintConfigFile: tsDetection.eslintConfigFile
|
|
3809
5169
|
};
|
|
3810
5170
|
const result = await validateESLint(context);
|
|
3811
5171
|
const durationMs = Date.now() - startTime;
|
|
@@ -3818,18 +5178,222 @@ async function lintCommand(options) {
|
|
|
3818
5178
|
}
|
|
3819
5179
|
}
|
|
3820
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
|
+
|
|
3821
5385
|
// src/validation/steps/typescript.ts
|
|
3822
5386
|
import { spawn as spawn3 } from "child_process";
|
|
3823
|
-
import { existsSync as
|
|
5387
|
+
import { existsSync as existsSync4, mkdirSync, rmSync, writeFileSync } from "fs";
|
|
3824
5388
|
import { mkdtemp } from "fs/promises";
|
|
3825
5389
|
import { tmpdir } from "os";
|
|
3826
|
-
import { isAbsolute as
|
|
5390
|
+
import { isAbsolute as isAbsolute3, join as join16 } from "path";
|
|
3827
5391
|
var defaultTypeScriptProcessRunner = { spawn: spawn3 };
|
|
3828
5392
|
var defaultTypeScriptDeps = {
|
|
3829
5393
|
mkdtemp,
|
|
3830
5394
|
writeFileSync,
|
|
3831
5395
|
rmSync,
|
|
3832
|
-
existsSync:
|
|
5396
|
+
existsSync: existsSync4,
|
|
3833
5397
|
mkdirSync
|
|
3834
5398
|
};
|
|
3835
5399
|
function buildTypeScriptArgs(context) {
|
|
@@ -3837,19 +5401,19 @@ function buildTypeScriptArgs(context) {
|
|
|
3837
5401
|
return scope === VALIDATION_SCOPES.FULL ? ["tsc", "--noEmit"] : ["tsc", "--project", configFile];
|
|
3838
5402
|
}
|
|
3839
5403
|
async function createFileSpecificTsconfig(scope, files, deps = defaultTypeScriptDeps) {
|
|
3840
|
-
const tempDir = await deps.mkdtemp(
|
|
3841
|
-
const configPath =
|
|
5404
|
+
const tempDir = await deps.mkdtemp(join16(tmpdir(), "validate-ts-"));
|
|
5405
|
+
const configPath = join16(tempDir, "tsconfig.json");
|
|
3842
5406
|
const baseConfigFile = TSCONFIG_FILES[scope];
|
|
3843
5407
|
const projectRoot = process.cwd();
|
|
3844
|
-
const absoluteFiles = files.map((file) =>
|
|
5408
|
+
const absoluteFiles = files.map((file) => isAbsolute3(file) ? file : join16(projectRoot, file));
|
|
3845
5409
|
const tempConfig = {
|
|
3846
|
-
extends:
|
|
5410
|
+
extends: join16(projectRoot, baseConfigFile),
|
|
3847
5411
|
files: absoluteFiles,
|
|
3848
5412
|
include: [],
|
|
3849
5413
|
exclude: [],
|
|
3850
5414
|
compilerOptions: {
|
|
3851
5415
|
noEmit: true,
|
|
3852
|
-
typeRoots: [
|
|
5416
|
+
typeRoots: [join16(projectRoot, "node_modules", "@types")],
|
|
3853
5417
|
types: ["node"]
|
|
3854
5418
|
}
|
|
3855
5419
|
};
|
|
@@ -3869,7 +5433,7 @@ async function validateTypeScript(scope, typescriptScope, files, runner = defaul
|
|
|
3869
5433
|
if (files && files.length > 0) {
|
|
3870
5434
|
const { configPath, cleanup } = await createFileSpecificTsconfig(scope, files, deps);
|
|
3871
5435
|
try {
|
|
3872
|
-
return await new Promise((
|
|
5436
|
+
return await new Promise((resolve6) => {
|
|
3873
5437
|
const tscProcess = runner.spawn("npx", ["tsc", "--project", configPath], {
|
|
3874
5438
|
cwd: process.cwd(),
|
|
3875
5439
|
stdio: "inherit"
|
|
@@ -3877,14 +5441,14 @@ async function validateTypeScript(scope, typescriptScope, files, runner = defaul
|
|
|
3877
5441
|
tscProcess.on("close", (code) => {
|
|
3878
5442
|
cleanup();
|
|
3879
5443
|
if (code === 0) {
|
|
3880
|
-
|
|
5444
|
+
resolve6({ success: true, skipped: false });
|
|
3881
5445
|
} else {
|
|
3882
|
-
|
|
5446
|
+
resolve6({ success: false, error: `TypeScript exited with code ${code}` });
|
|
3883
5447
|
}
|
|
3884
5448
|
});
|
|
3885
5449
|
tscProcess.on("error", (error) => {
|
|
3886
5450
|
cleanup();
|
|
3887
|
-
|
|
5451
|
+
resolve6({ success: false, error: error.message });
|
|
3888
5452
|
});
|
|
3889
5453
|
});
|
|
3890
5454
|
} catch (error) {
|
|
@@ -3896,56 +5460,37 @@ async function validateTypeScript(scope, typescriptScope, files, runner = defaul
|
|
|
3896
5460
|
tool = "npx";
|
|
3897
5461
|
tscArgs = buildTypeScriptArgs({ scope, configFile });
|
|
3898
5462
|
}
|
|
3899
|
-
return new Promise((
|
|
5463
|
+
return new Promise((resolve6) => {
|
|
3900
5464
|
const tscProcess = runner.spawn(tool, tscArgs, {
|
|
3901
5465
|
cwd: process.cwd(),
|
|
3902
5466
|
stdio: "inherit"
|
|
3903
5467
|
});
|
|
3904
5468
|
tscProcess.on("close", (code) => {
|
|
3905
5469
|
if (code === 0) {
|
|
3906
|
-
|
|
5470
|
+
resolve6({ success: true, skipped: false });
|
|
3907
5471
|
} else {
|
|
3908
|
-
|
|
5472
|
+
resolve6({ success: false, error: `TypeScript exited with code ${code}` });
|
|
3909
5473
|
}
|
|
3910
5474
|
});
|
|
3911
5475
|
tscProcess.on("error", (error) => {
|
|
3912
|
-
|
|
5476
|
+
resolve6({ success: false, error: error.message });
|
|
3913
5477
|
});
|
|
3914
5478
|
});
|
|
3915
5479
|
}
|
|
3916
|
-
var typescriptStep = {
|
|
3917
|
-
id: STEP_IDS.TYPESCRIPT,
|
|
3918
|
-
name: STEP_NAMES.TYPESCRIPT,
|
|
3919
|
-
description: STEP_DESCRIPTIONS.TYPESCRIPT,
|
|
3920
|
-
enabled: (context) => context.enabledValidations[VALIDATION_KEYS.TYPESCRIPT] === true && validationEnabled(VALIDATION_KEYS.TYPESCRIPT),
|
|
3921
|
-
execute: async (context) => {
|
|
3922
|
-
const startTime = performance.now();
|
|
3923
|
-
try {
|
|
3924
|
-
const result = await validateTypeScript(
|
|
3925
|
-
context.scope,
|
|
3926
|
-
context.scopeConfig,
|
|
3927
|
-
context.validatedFiles
|
|
3928
|
-
);
|
|
3929
|
-
return {
|
|
3930
|
-
success: result.success,
|
|
3931
|
-
error: result.error,
|
|
3932
|
-
duration: performance.now() - startTime,
|
|
3933
|
-
skipped: result.skipped
|
|
3934
|
-
};
|
|
3935
|
-
} catch (error) {
|
|
3936
|
-
return {
|
|
3937
|
-
success: false,
|
|
3938
|
-
error: error instanceof Error ? error.message : String(error),
|
|
3939
|
-
duration: performance.now() - startTime
|
|
3940
|
-
};
|
|
3941
|
-
}
|
|
3942
|
-
}
|
|
3943
|
-
};
|
|
3944
5480
|
|
|
3945
5481
|
// src/commands/validation/typescript.ts
|
|
5482
|
+
var TYPESCRIPT_ABSENT_MESSAGE4 = "\u23ED Skipping TypeScript (TypeScript not detected in project)";
|
|
3946
5483
|
async function typescriptCommand(options) {
|
|
3947
5484
|
const { cwd, scope = "full", files, quiet } = options;
|
|
3948
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
|
+
}
|
|
3949
5494
|
const toolResult = await discoverTool("typescript", { projectRoot: cwd });
|
|
3950
5495
|
if (!toolResult.found) {
|
|
3951
5496
|
const skipMessage = formatSkipMessage("TypeScript", toolResult);
|
|
@@ -3964,7 +5509,7 @@ async function typescriptCommand(options) {
|
|
|
3964
5509
|
}
|
|
3965
5510
|
|
|
3966
5511
|
// src/commands/validation/all.ts
|
|
3967
|
-
var TOTAL_STEPS =
|
|
5512
|
+
var TOTAL_STEPS = 6;
|
|
3968
5513
|
function formatStepWithTiming(stepNumber, result, quiet) {
|
|
3969
5514
|
if (quiet || !result.output) return "";
|
|
3970
5515
|
const timing = result.durationMs === void 0 ? "" : ` (${formatDuration(result.durationMs)})`;
|
|
@@ -3990,6 +5535,14 @@ async function allCommand(options) {
|
|
|
3990
5535
|
const tsOutput = formatStepWithTiming(4, tsResult, quiet);
|
|
3991
5536
|
if (tsOutput) outputs.push(tsOutput);
|
|
3992
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;
|
|
3993
5546
|
const totalDurationMs = Date.now() - startTime;
|
|
3994
5547
|
if (!quiet) {
|
|
3995
5548
|
const summary = formatSummary({ success: !hasFailure, totalDurationMs });
|
|
@@ -4002,12 +5555,121 @@ async function allCommand(options) {
|
|
|
4002
5555
|
};
|
|
4003
5556
|
}
|
|
4004
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
|
+
|
|
4005
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);
|
|
4006
5660
|
function addCommonOptions(cmd) {
|
|
4007
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");
|
|
4008
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
|
+
}
|
|
4009
5670
|
function registerValidationCommands(validationCmd) {
|
|
4010
|
-
const
|
|
5671
|
+
const { subcommands } = validationCliDefinition;
|
|
5672
|
+
const tsCmd = addValidationSubcommand(validationCmd, subcommands.typescript).action(async (options) => {
|
|
4011
5673
|
const result = await typescriptCommand({
|
|
4012
5674
|
cwd: process.cwd(),
|
|
4013
5675
|
scope: options.scope,
|
|
@@ -4019,7 +5681,7 @@ function registerValidationCommands(validationCmd) {
|
|
|
4019
5681
|
process.exit(result.exitCode);
|
|
4020
5682
|
});
|
|
4021
5683
|
addCommonOptions(tsCmd);
|
|
4022
|
-
const lintCmd = validationCmd.
|
|
5684
|
+
const lintCmd = addValidationSubcommand(validationCmd, subcommands.lint).option("--fix", "Auto-fix issues").action(async (options) => {
|
|
4023
5685
|
const result = await lintCommand({
|
|
4024
5686
|
cwd: process.cwd(),
|
|
4025
5687
|
scope: options.scope,
|
|
@@ -4032,7 +5694,7 @@ function registerValidationCommands(validationCmd) {
|
|
|
4032
5694
|
process.exit(result.exitCode);
|
|
4033
5695
|
});
|
|
4034
5696
|
addCommonOptions(lintCmd);
|
|
4035
|
-
const circularCmd = validationCmd.
|
|
5697
|
+
const circularCmd = addValidationSubcommand(validationCmd, subcommands.circular).action(async (options) => {
|
|
4036
5698
|
const result = await circularCommand({
|
|
4037
5699
|
cwd: process.cwd(),
|
|
4038
5700
|
quiet: options.quiet,
|
|
@@ -4042,7 +5704,7 @@ function registerValidationCommands(validationCmd) {
|
|
|
4042
5704
|
process.exit(result.exitCode);
|
|
4043
5705
|
});
|
|
4044
5706
|
addCommonOptions(circularCmd);
|
|
4045
|
-
const knipCmd = validationCmd.
|
|
5707
|
+
const knipCmd = addValidationSubcommand(validationCmd, subcommands.knip).action(async (options) => {
|
|
4046
5708
|
const result = await knipCommand({
|
|
4047
5709
|
cwd: process.cwd(),
|
|
4048
5710
|
quiet: options.quiet,
|
|
@@ -4052,7 +5714,42 @@ function registerValidationCommands(validationCmd) {
|
|
|
4052
5714
|
process.exit(result.exitCode);
|
|
4053
5715
|
});
|
|
4054
5716
|
addCommonOptions(knipCmd);
|
|
4055
|
-
const
|
|
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) => {
|
|
4056
5753
|
const result = await allCommand({
|
|
4057
5754
|
cwd: process.cwd(),
|
|
4058
5755
|
scope: options.scope,
|
|
@@ -4066,11 +5763,24 @@ function registerValidationCommands(validationCmd) {
|
|
|
4066
5763
|
});
|
|
4067
5764
|
addCommonOptions(allCmd);
|
|
4068
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
|
+
}
|
|
4069
5775
|
var validationDomain = {
|
|
4070
|
-
name:
|
|
4071
|
-
description:
|
|
5776
|
+
name: validationCliDefinition.domain.commandName,
|
|
5777
|
+
description: validationCliDefinition.domain.description,
|
|
4072
5778
|
register: (program2) => {
|
|
4073
|
-
const
|
|
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
|
+
});
|
|
4074
5784
|
registerValidationCommands(validationCmd);
|
|
4075
5785
|
}
|
|
4076
5786
|
};
|
|
@@ -4080,7 +5790,9 @@ var require3 = createRequire2(import.meta.url);
|
|
|
4080
5790
|
var { version } = require3("../package.json");
|
|
4081
5791
|
var program = new Command();
|
|
4082
5792
|
program.name("spx").description("Fast, deterministic CLI tool for spec workflow management").version(version);
|
|
5793
|
+
auditDomain.register(program);
|
|
4083
5794
|
claudeDomain.register(program);
|
|
5795
|
+
configDomain.register(program);
|
|
4084
5796
|
sessionDomain.register(program);
|
|
4085
5797
|
specDomain.register(program);
|
|
4086
5798
|
validationDomain.register(program);
|