@outcomeeng/spx 0.2.0 → 0.3.1
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 +1954 -467
- 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);
|
|
1629
|
+
}
|
|
1630
|
+
const location = findSessionForArchive(existingPaths);
|
|
1631
|
+
if (!location) {
|
|
1632
|
+
throw new SessionNotFoundError(sessionId);
|
|
944
1633
|
}
|
|
945
|
-
|
|
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})$/;
|
|
@@ -1017,6 +1707,17 @@ var PRIORITY_ORDER = {
|
|
|
1017
1707
|
low: 2
|
|
1018
1708
|
};
|
|
1019
1709
|
var DEFAULT_PRIORITY = "medium";
|
|
1710
|
+
var SESSION_FRONT_MATTER = {
|
|
1711
|
+
PRIORITY: "priority",
|
|
1712
|
+
TAGS: "tags",
|
|
1713
|
+
ID: "id",
|
|
1714
|
+
BRANCH: "branch",
|
|
1715
|
+
CREATED_AT: "created_at",
|
|
1716
|
+
AGENT_SESSION_ID: "agent_session_id",
|
|
1717
|
+
WORKING_DIRECTORY: "working_directory",
|
|
1718
|
+
SPECS: "specs",
|
|
1719
|
+
FILES: "files"
|
|
1720
|
+
};
|
|
1020
1721
|
|
|
1021
1722
|
// src/session/list.ts
|
|
1022
1723
|
var FRONT_MATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)\r?\n?/;
|
|
@@ -1032,43 +1733,35 @@ function parseSessionMetadata(content) {
|
|
|
1032
1733
|
};
|
|
1033
1734
|
}
|
|
1034
1735
|
try {
|
|
1035
|
-
const parsed =
|
|
1736
|
+
const parsed = parseYaml2(match[1]);
|
|
1036
1737
|
if (!parsed || typeof parsed !== "object") {
|
|
1037
1738
|
return {
|
|
1038
1739
|
priority: DEFAULT_PRIORITY,
|
|
1039
1740
|
tags: []
|
|
1040
1741
|
};
|
|
1041
1742
|
}
|
|
1042
|
-
const
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
}
|
|
1047
|
-
const
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
if (typeof
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
metadata.specs = parsed.specs.filter(
|
|
1065
|
-
(s) => typeof s === "string"
|
|
1066
|
-
);
|
|
1067
|
-
}
|
|
1068
|
-
if (Array.isArray(parsed.files)) {
|
|
1069
|
-
metadata.files = parsed.files.filter(
|
|
1070
|
-
(f) => typeof f === "string"
|
|
1071
|
-
);
|
|
1743
|
+
const rawPriority = parsed[SESSION_FRONT_MATTER.PRIORITY];
|
|
1744
|
+
const priority = isValidPriority(rawPriority) ? rawPriority : DEFAULT_PRIORITY;
|
|
1745
|
+
const rawTags = parsed[SESSION_FRONT_MATTER.TAGS];
|
|
1746
|
+
const tags = Array.isArray(rawTags) ? rawTags.filter((t) => typeof t === "string") : [];
|
|
1747
|
+
const metadata = { priority, tags };
|
|
1748
|
+
const id = parsed[SESSION_FRONT_MATTER.ID];
|
|
1749
|
+
if (typeof id === "string") metadata.id = id;
|
|
1750
|
+
const branch = parsed[SESSION_FRONT_MATTER.BRANCH];
|
|
1751
|
+
if (typeof branch === "string") metadata.branch = branch;
|
|
1752
|
+
const createdAt = parsed[SESSION_FRONT_MATTER.CREATED_AT];
|
|
1753
|
+
if (typeof createdAt === "string") metadata.createdAt = createdAt;
|
|
1754
|
+
const agentSessionId = parsed[SESSION_FRONT_MATTER.AGENT_SESSION_ID];
|
|
1755
|
+
if (typeof agentSessionId === "string") metadata.agentSessionId = agentSessionId;
|
|
1756
|
+
const workingDirectory = parsed[SESSION_FRONT_MATTER.WORKING_DIRECTORY];
|
|
1757
|
+
if (typeof workingDirectory === "string") metadata.workingDirectory = workingDirectory;
|
|
1758
|
+
const specs = parsed[SESSION_FRONT_MATTER.SPECS];
|
|
1759
|
+
if (Array.isArray(specs)) {
|
|
1760
|
+
metadata.specs = specs.filter((s) => typeof s === "string");
|
|
1761
|
+
}
|
|
1762
|
+
const files = parsed[SESSION_FRONT_MATTER.FILES];
|
|
1763
|
+
if (Array.isArray(files)) {
|
|
1764
|
+
metadata.files = files.filter((f) => typeof f === "string");
|
|
1072
1765
|
}
|
|
1073
1766
|
return metadata;
|
|
1074
1767
|
} catch {
|
|
@@ -1097,9 +1790,9 @@ function sortSessions(sessions) {
|
|
|
1097
1790
|
// src/session/show.ts
|
|
1098
1791
|
var { dir: sessionsBaseDir, statusDirs } = DEFAULT_CONFIG.sessions;
|
|
1099
1792
|
var DEFAULT_SESSION_CONFIG = {
|
|
1100
|
-
todoDir:
|
|
1101
|
-
doingDir:
|
|
1102
|
-
archiveDir:
|
|
1793
|
+
todoDir: join4(sessionsBaseDir, statusDirs.todo),
|
|
1794
|
+
doingDir: join4(sessionsBaseDir, statusDirs.doing),
|
|
1795
|
+
archiveDir: join4(sessionsBaseDir, statusDirs.archive)
|
|
1103
1796
|
};
|
|
1104
1797
|
var SEARCH_ORDER = [...SESSION_STATUSES];
|
|
1105
1798
|
function resolveSessionPaths(id, config = DEFAULT_SESSION_CONFIG) {
|
|
@@ -1136,11 +1829,11 @@ function formatShowOutput(content, options) {
|
|
|
1136
1829
|
// src/commands/session/delete.ts
|
|
1137
1830
|
async function findExistingPaths(paths) {
|
|
1138
1831
|
const existing = [];
|
|
1139
|
-
for (const
|
|
1832
|
+
for (const path9 of paths) {
|
|
1140
1833
|
try {
|
|
1141
|
-
const stats = await stat2(
|
|
1834
|
+
const stats = await stat2(path9);
|
|
1142
1835
|
if (stats.isFile()) {
|
|
1143
|
-
existing.push(
|
|
1836
|
+
existing.push(path9);
|
|
1144
1837
|
}
|
|
1145
1838
|
} catch {
|
|
1146
1839
|
}
|
|
@@ -1161,10 +1854,20 @@ async function deleteCommand(options) {
|
|
|
1161
1854
|
|
|
1162
1855
|
// src/commands/session/handoff.ts
|
|
1163
1856
|
import { mkdir as mkdir2, writeFile } from "fs/promises";
|
|
1164
|
-
import { join as
|
|
1857
|
+
import { join as join5, resolve as resolve4 } from "path";
|
|
1165
1858
|
|
|
1166
1859
|
// src/session/create.ts
|
|
1167
1860
|
var MIN_CONTENT_LENGTH = 1;
|
|
1861
|
+
var FRONT_MATTER_OPEN = /^---\r?\n/;
|
|
1862
|
+
function preFillSessionContent(content, params) {
|
|
1863
|
+
const match = FRONT_MATTER_OPEN.exec(content);
|
|
1864
|
+
if (!match) return content;
|
|
1865
|
+
const lines = [`${SESSION_FRONT_MATTER.CREATED_AT}: "${params.createdAt.toISOString()}"`];
|
|
1866
|
+
if (params.agentSessionId !== void 0) {
|
|
1867
|
+
lines.push(`${SESSION_FRONT_MATTER.AGENT_SESSION_ID}: "${params.agentSessionId}"`);
|
|
1868
|
+
}
|
|
1869
|
+
return match[0] + lines.join("\n") + "\n" + content.slice(match[0].length);
|
|
1870
|
+
}
|
|
1168
1871
|
function validateSessionContent(content) {
|
|
1169
1872
|
if (!content || content.trim().length < MIN_CONTENT_LENGTH) {
|
|
1170
1873
|
return {
|
|
@@ -1172,12 +1875,6 @@ function validateSessionContent(content) {
|
|
|
1172
1875
|
error: "Session content cannot be empty"
|
|
1173
1876
|
};
|
|
1174
1877
|
}
|
|
1175
|
-
if (content.trim().length === 0) {
|
|
1176
|
-
return {
|
|
1177
|
-
valid: false,
|
|
1178
|
-
error: "Session content cannot be only whitespace"
|
|
1179
|
-
};
|
|
1180
|
-
}
|
|
1181
1878
|
return { valid: true };
|
|
1182
1879
|
}
|
|
1183
1880
|
|
|
@@ -1208,14 +1905,19 @@ ${content}`;
|
|
|
1208
1905
|
async function handoffCommand(options) {
|
|
1209
1906
|
const { config, warning } = await resolveSessionConfig({ sessionsDir: options.sessionsDir });
|
|
1210
1907
|
const sessionId = generateSessionId();
|
|
1211
|
-
const
|
|
1908
|
+
const baseContent = buildSessionContent(options.content);
|
|
1909
|
+
const agentSessionId = process.env.CLAUDE_SESSION_ID ?? process.env.CODEX_THREAD_ID;
|
|
1910
|
+
const fullContent = preFillSessionContent(baseContent, {
|
|
1911
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1912
|
+
agentSessionId
|
|
1913
|
+
});
|
|
1212
1914
|
const validation = validateSessionContent(fullContent);
|
|
1213
1915
|
if (!validation.valid) {
|
|
1214
1916
|
throw new SessionInvalidContentError(validation.error ?? "Unknown validation error");
|
|
1215
1917
|
}
|
|
1216
1918
|
const filename = `${sessionId}.md`;
|
|
1217
|
-
const sessionPath =
|
|
1218
|
-
const absolutePath =
|
|
1919
|
+
const sessionPath = join5(config.todoDir, filename);
|
|
1920
|
+
const absolutePath = resolve4(sessionPath);
|
|
1219
1921
|
await mkdir2(config.todoDir, { recursive: true });
|
|
1220
1922
|
await writeFile(sessionPath, fullContent, "utf-8");
|
|
1221
1923
|
if (warning) {
|
|
@@ -1227,8 +1929,8 @@ async function handoffCommand(options) {
|
|
|
1227
1929
|
}
|
|
1228
1930
|
|
|
1229
1931
|
// src/commands/session/list.ts
|
|
1230
|
-
import { readdir, readFile } from "fs/promises";
|
|
1231
|
-
import { join as
|
|
1932
|
+
import { readdir, readFile as readFile3 } from "fs/promises";
|
|
1933
|
+
import { join as join6 } from "path";
|
|
1232
1934
|
async function loadSessionsFromDir(dir, status) {
|
|
1233
1935
|
try {
|
|
1234
1936
|
const files = await readdir(dir);
|
|
@@ -1236,8 +1938,8 @@ async function loadSessionsFromDir(dir, status) {
|
|
|
1236
1938
|
for (const file of files) {
|
|
1237
1939
|
if (!file.endsWith(".md")) continue;
|
|
1238
1940
|
const id = file.replace(".md", "");
|
|
1239
|
-
const filePath =
|
|
1240
|
-
const content = await
|
|
1941
|
+
const filePath = join6(dir, file);
|
|
1942
|
+
const content = await readFile3(filePath, "utf-8");
|
|
1241
1943
|
const metadata = parseSessionMetadata(content);
|
|
1242
1944
|
sessions.push({
|
|
1243
1945
|
id,
|
|
@@ -1299,8 +2001,8 @@ async function listCommand(options) {
|
|
|
1299
2001
|
}
|
|
1300
2002
|
|
|
1301
2003
|
// src/commands/session/pickup.ts
|
|
1302
|
-
import { mkdir as mkdir3, readdir as readdir2, readFile as
|
|
1303
|
-
import { join as
|
|
2004
|
+
import { mkdir as mkdir3, readdir as readdir2, readFile as readFile4, rename as rename2 } from "fs/promises";
|
|
2005
|
+
import { join as join7 } from "path";
|
|
1304
2006
|
|
|
1305
2007
|
// src/session/pickup.ts
|
|
1306
2008
|
function buildClaimPaths(sessionId, config) {
|
|
@@ -1345,8 +2047,8 @@ async function loadTodoSessions(config) {
|
|
|
1345
2047
|
for (const file of files) {
|
|
1346
2048
|
if (!file.endsWith(".md")) continue;
|
|
1347
2049
|
const id = file.replace(".md", "");
|
|
1348
|
-
const filePath =
|
|
1349
|
-
const content = await
|
|
2050
|
+
const filePath = join7(config.todoDir, file);
|
|
2051
|
+
const content = await readFile4(filePath, "utf-8");
|
|
1350
2052
|
const metadata = parseSessionMetadata(content);
|
|
1351
2053
|
sessions.push({
|
|
1352
2054
|
id,
|
|
@@ -1385,7 +2087,7 @@ async function pickupCommand(options) {
|
|
|
1385
2087
|
} catch (error) {
|
|
1386
2088
|
throw classifyClaimError(error, sessionId);
|
|
1387
2089
|
}
|
|
1388
|
-
const content = await
|
|
2090
|
+
const content = await readFile4(paths.target, "utf-8");
|
|
1389
2091
|
const output = formatShowOutput(content, { status: PICKUP_TARGET_STATUS });
|
|
1390
2092
|
return `Claimed session <PICKUP_ID>${sessionId}</PICKUP_ID>
|
|
1391
2093
|
|
|
@@ -1393,10 +2095,30 @@ ${output}`;
|
|
|
1393
2095
|
}
|
|
1394
2096
|
|
|
1395
2097
|
// src/commands/session/prune.ts
|
|
1396
|
-
import { readdir as readdir3, readFile as
|
|
1397
|
-
import { join as
|
|
1398
|
-
|
|
2098
|
+
import { readdir as readdir3, readFile as readFile5, unlink as unlink2 } from "fs/promises";
|
|
2099
|
+
import { join as join8 } from "path";
|
|
2100
|
+
|
|
2101
|
+
// src/session/prune.ts
|
|
1399
2102
|
var DEFAULT_KEEP_COUNT = 5;
|
|
2103
|
+
function selectSessionsToDelete(sessions, options = {}) {
|
|
2104
|
+
const keep = options.keep ?? DEFAULT_KEEP_COUNT;
|
|
2105
|
+
if (sessions.length <= keep) {
|
|
2106
|
+
return [];
|
|
2107
|
+
}
|
|
2108
|
+
const sorted = [...sessions].sort((a, b) => {
|
|
2109
|
+
const dateA = parseSessionId(a.id);
|
|
2110
|
+
const dateB = parseSessionId(b.id);
|
|
2111
|
+
if (!dateA && !dateB) return 0;
|
|
2112
|
+
if (!dateA) return -1;
|
|
2113
|
+
if (!dateB) return 1;
|
|
2114
|
+
return dateA.getTime() - dateB.getTime();
|
|
2115
|
+
});
|
|
2116
|
+
const deleteCount = sessions.length - keep;
|
|
2117
|
+
return sorted.slice(0, deleteCount);
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
// src/commands/session/prune.ts
|
|
2121
|
+
var PRUNE_STATUS = SESSION_STATUSES[2];
|
|
1400
2122
|
var PruneValidationError = class extends Error {
|
|
1401
2123
|
constructor(message) {
|
|
1402
2124
|
super(message);
|
|
@@ -1419,8 +2141,8 @@ async function loadArchiveSessions(config) {
|
|
|
1419
2141
|
for (const file of files) {
|
|
1420
2142
|
if (!file.endsWith(".md")) continue;
|
|
1421
2143
|
const id = file.replace(".md", "");
|
|
1422
|
-
const filePath =
|
|
1423
|
-
const content = await
|
|
2144
|
+
const filePath = join8(config.archiveDir, file);
|
|
2145
|
+
const content = await readFile5(filePath, "utf-8");
|
|
1424
2146
|
const metadata = parseSessionMetadata(content);
|
|
1425
2147
|
sessions.push({
|
|
1426
2148
|
id,
|
|
@@ -1437,20 +2159,13 @@ async function loadArchiveSessions(config) {
|
|
|
1437
2159
|
throw error;
|
|
1438
2160
|
}
|
|
1439
2161
|
}
|
|
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
2162
|
async function pruneCommand(options) {
|
|
1448
2163
|
validatePruneOptions(options);
|
|
1449
2164
|
const keep = options.keep ?? DEFAULT_KEEP_COUNT;
|
|
1450
2165
|
const dryRun = options.dryRun ?? false;
|
|
1451
2166
|
const { config } = await resolveSessionConfig({ sessionsDir: options.sessionsDir });
|
|
1452
2167
|
const sessions = await loadArchiveSessions(config);
|
|
1453
|
-
const toPrune =
|
|
2168
|
+
const toPrune = selectSessionsToDelete(sessions, { keep });
|
|
1454
2169
|
if (toPrune.length === 0) {
|
|
1455
2170
|
return `No sessions to prune. ${sessions.length} sessions kept.`;
|
|
1456
2171
|
}
|
|
@@ -1512,19 +2227,7 @@ async function loadDoingSessions(config) {
|
|
|
1512
2227
|
throw error;
|
|
1513
2228
|
}
|
|
1514
2229
|
}
|
|
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
|
-
}
|
|
2230
|
+
async function releaseSingle(sessionId, config) {
|
|
1528
2231
|
const paths = buildReleasePaths(sessionId, config);
|
|
1529
2232
|
try {
|
|
1530
2233
|
await rename3(paths.source, paths.target);
|
|
@@ -1537,9 +2240,22 @@ async function releaseCommand(options) {
|
|
|
1537
2240
|
return `Released session: ${sessionId}
|
|
1538
2241
|
Session returned to todo directory.`;
|
|
1539
2242
|
}
|
|
2243
|
+
async function releaseCommand(options) {
|
|
2244
|
+
const { config } = await resolveSessionConfig({ sessionsDir: options.sessionsDir });
|
|
2245
|
+
let ids = options.sessionIds;
|
|
2246
|
+
if (ids.length === 0) {
|
|
2247
|
+
const sessions = await loadDoingSessions(config);
|
|
2248
|
+
const current = findCurrentSession(sessions);
|
|
2249
|
+
if (!current) {
|
|
2250
|
+
throw new SessionNotClaimedError("(none)");
|
|
2251
|
+
}
|
|
2252
|
+
ids = [current.id];
|
|
2253
|
+
}
|
|
2254
|
+
return processBatch(ids, (id) => releaseSingle(id, config));
|
|
2255
|
+
}
|
|
1540
2256
|
|
|
1541
2257
|
// src/commands/session/show.ts
|
|
1542
|
-
import { readFile as
|
|
2258
|
+
import { readFile as readFile6, stat as stat3 } from "fs/promises";
|
|
1543
2259
|
async function findExistingPath(paths, _config) {
|
|
1544
2260
|
for (let i = 0; i < paths.length; i++) {
|
|
1545
2261
|
const filePath = paths[i];
|
|
@@ -1559,10 +2275,10 @@ async function showSingle(sessionId, config) {
|
|
|
1559
2275
|
if (!found) {
|
|
1560
2276
|
throw new SessionNotFoundError(sessionId);
|
|
1561
2277
|
}
|
|
1562
|
-
const content = await
|
|
2278
|
+
const content = await readFile6(found.path, "utf-8");
|
|
1563
2279
|
return formatShowOutput(content, { status: found.status });
|
|
1564
2280
|
}
|
|
1565
|
-
async function
|
|
2281
|
+
async function showCommand2(options) {
|
|
1566
2282
|
const { config } = await resolveSessionConfig({ sessionsDir: options.sessionsDir });
|
|
1567
2283
|
return processBatch(options.sessionIds, (id) => showSingle(id, config));
|
|
1568
2284
|
}
|
|
@@ -1630,17 +2346,17 @@ async function readStdin() {
|
|
|
1630
2346
|
if (process.stdin.isTTY) {
|
|
1631
2347
|
return void 0;
|
|
1632
2348
|
}
|
|
1633
|
-
return new Promise((
|
|
2349
|
+
return new Promise((resolve6) => {
|
|
1634
2350
|
let data = "";
|
|
1635
2351
|
process.stdin.setEncoding("utf-8");
|
|
1636
2352
|
process.stdin.on("data", (chunk) => {
|
|
1637
2353
|
data += chunk;
|
|
1638
2354
|
});
|
|
1639
2355
|
process.stdin.on("end", () => {
|
|
1640
|
-
|
|
2356
|
+
resolve6(data.trim() || void 0);
|
|
1641
2357
|
});
|
|
1642
2358
|
process.stdin.on("error", () => {
|
|
1643
|
-
|
|
2359
|
+
resolve6(void 0);
|
|
1644
2360
|
});
|
|
1645
2361
|
});
|
|
1646
2362
|
}
|
|
@@ -1675,7 +2391,7 @@ function registerSessionCommands(sessionCmd) {
|
|
|
1675
2391
|
});
|
|
1676
2392
|
sessionCmd.command("show <id...>").description("Show session content").option("--sessions-dir <path>", "Custom sessions directory").action(async (ids, options) => {
|
|
1677
2393
|
try {
|
|
1678
|
-
const output = await
|
|
2394
|
+
const output = await showCommand2({
|
|
1679
2395
|
sessionIds: ids,
|
|
1680
2396
|
sessionsDir: options.sessionsDir
|
|
1681
2397
|
});
|
|
@@ -1700,10 +2416,10 @@ function registerSessionCommands(sessionCmd) {
|
|
|
1700
2416
|
handleError(error);
|
|
1701
2417
|
}
|
|
1702
2418
|
});
|
|
1703
|
-
sessionCmd.command("release [
|
|
2419
|
+
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
2420
|
try {
|
|
1705
2421
|
const output = await releaseCommand({
|
|
1706
|
-
|
|
2422
|
+
sessionIds: ids,
|
|
1707
2423
|
sessionsDir: options.sessionsDir
|
|
1708
2424
|
});
|
|
1709
2425
|
console.log(output);
|
|
@@ -1777,8 +2493,7 @@ var sessionDomain = {
|
|
|
1777
2493
|
};
|
|
1778
2494
|
|
|
1779
2495
|
// src/domains/spec/index.ts
|
|
1780
|
-
import { access as access2 } from "fs/promises";
|
|
1781
|
-
import { readFile as readFile5, writeFile as writeFile2 } from "fs/promises";
|
|
2496
|
+
import { access as access2, readFile as readFile7, writeFile as writeFile2 } from "fs/promises";
|
|
1782
2497
|
|
|
1783
2498
|
// src/scanner/scanner.ts
|
|
1784
2499
|
import path5 from "path";
|
|
@@ -1897,34 +2612,28 @@ var StatusDeterminationError = class extends Error {
|
|
|
1897
2612
|
};
|
|
1898
2613
|
async function getWorkItemStatus(workItemPath) {
|
|
1899
2614
|
try {
|
|
1900
|
-
try {
|
|
1901
|
-
await access(workItemPath);
|
|
1902
|
-
} catch (error) {
|
|
1903
|
-
if (error.code === "ENOENT") {
|
|
1904
|
-
throw new Error(`Work item not found: ${workItemPath}`);
|
|
1905
|
-
}
|
|
1906
|
-
throw error;
|
|
1907
|
-
}
|
|
1908
2615
|
const testsPath = path6.join(workItemPath, "tests");
|
|
1909
|
-
let
|
|
2616
|
+
let entries;
|
|
1910
2617
|
try {
|
|
1911
|
-
await
|
|
1912
|
-
hasTests = true;
|
|
2618
|
+
entries = await readdir5(testsPath);
|
|
1913
2619
|
} catch (error) {
|
|
1914
2620
|
if (error.code === "ENOENT") {
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
2621
|
+
try {
|
|
2622
|
+
await access(workItemPath);
|
|
2623
|
+
} catch (workItemError) {
|
|
2624
|
+
if (workItemError.code === "ENOENT") {
|
|
2625
|
+
throw new Error(`Work item not found: ${workItemPath}`);
|
|
2626
|
+
}
|
|
2627
|
+
throw workItemError;
|
|
2628
|
+
}
|
|
2629
|
+
return determineStatus({
|
|
2630
|
+
hasTestsDir: false,
|
|
2631
|
+
hasDoneMd: false,
|
|
2632
|
+
testsIsEmpty: true
|
|
2633
|
+
});
|
|
1918
2634
|
}
|
|
2635
|
+
throw error;
|
|
1919
2636
|
}
|
|
1920
|
-
if (!hasTests) {
|
|
1921
|
-
return determineStatus({
|
|
1922
|
-
hasTestsDir: false,
|
|
1923
|
-
hasDoneMd: false,
|
|
1924
|
-
testsIsEmpty: true
|
|
1925
|
-
});
|
|
1926
|
-
}
|
|
1927
|
-
const entries = await readdir5(testsPath);
|
|
1928
2637
|
const hasDone = entries.includes("DONE.md");
|
|
1929
2638
|
if (hasDone) {
|
|
1930
2639
|
const donePath = path6.join(testsPath, "DONE.md");
|
|
@@ -2044,6 +2753,14 @@ function rollupStatus(nodes) {
|
|
|
2044
2753
|
}
|
|
2045
2754
|
|
|
2046
2755
|
// src/commands/spec/next.ts
|
|
2756
|
+
var EMPTY_WORK_ITEMS_MESSAGE = "No work items found in specs/work/doing";
|
|
2757
|
+
var ALL_COMPLETE_MESSAGE = "All work items are complete! \u{1F389}";
|
|
2758
|
+
var NEXT_WORK_ITEM_HEADING = "Next work item:";
|
|
2759
|
+
var STATUS_LABEL = "Status";
|
|
2760
|
+
var PATH_LABEL = "Path";
|
|
2761
|
+
var INDENT = " ";
|
|
2762
|
+
var BLANK_LINE = "";
|
|
2763
|
+
var PATH_SEPARATOR = " > ";
|
|
2047
2764
|
function findNextWorkItem(tree) {
|
|
2048
2765
|
return findFirstNonDoneLeaf(tree.nodes);
|
|
2049
2766
|
}
|
|
@@ -2053,46 +2770,18 @@ function findFirstNonDoneLeaf(nodes) {
|
|
|
2053
2770
|
if (node.status !== "DONE") {
|
|
2054
2771
|
return node;
|
|
2055
2772
|
}
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2773
|
+
continue;
|
|
2774
|
+
}
|
|
2775
|
+
const found = findFirstNonDoneLeaf(node.children);
|
|
2776
|
+
if (found !== null) {
|
|
2777
|
+
return found;
|
|
2061
2778
|
}
|
|
2062
2779
|
}
|
|
2063
2780
|
return null;
|
|
2064
2781
|
}
|
|
2065
2782
|
function formatWorkItemName(node) {
|
|
2066
|
-
const
|
|
2067
|
-
return `${node.kind}-${
|
|
2068
|
-
}
|
|
2069
|
-
async function nextCommand(options = {}) {
|
|
2070
|
-
const cwd = options.cwd || process.cwd();
|
|
2071
|
-
const scanner = new Scanner(cwd, DEFAULT_CONFIG);
|
|
2072
|
-
const workItems = await scanner.scan();
|
|
2073
|
-
if (workItems.length === 0) {
|
|
2074
|
-
return `No work items found in ${DEFAULT_CONFIG.specs.root}/${DEFAULT_CONFIG.specs.work.dir}/${DEFAULT_CONFIG.specs.work.statusDirs.doing}`;
|
|
2075
|
-
}
|
|
2076
|
-
const tree = await buildTree(workItems);
|
|
2077
|
-
const next = findNextWorkItem(tree);
|
|
2078
|
-
if (!next) {
|
|
2079
|
-
return "All work items are complete! \u{1F389}";
|
|
2080
|
-
}
|
|
2081
|
-
const parents = findParents(tree.nodes, next);
|
|
2082
|
-
const lines = [];
|
|
2083
|
-
lines.push("Next work item:");
|
|
2084
|
-
lines.push("");
|
|
2085
|
-
if (parents.capability && parents.feature) {
|
|
2086
|
-
lines.push(
|
|
2087
|
-
` ${formatWorkItemName(parents.capability)} > ${formatWorkItemName(parents.feature)} > ${formatWorkItemName(next)}`
|
|
2088
|
-
);
|
|
2089
|
-
} else {
|
|
2090
|
-
lines.push(` ${formatWorkItemName(next)}`);
|
|
2091
|
-
}
|
|
2092
|
-
lines.push("");
|
|
2093
|
-
lines.push(` Status: ${next.status}`);
|
|
2094
|
-
lines.push(` Path: ${next.path}`);
|
|
2095
|
-
return lines.join("\n");
|
|
2783
|
+
const displayNumber = node.kind === "capability" ? node.number + 1 : node.number;
|
|
2784
|
+
return `${node.kind}-${displayNumber}_${node.slug}`;
|
|
2096
2785
|
}
|
|
2097
2786
|
function findParents(nodes, target) {
|
|
2098
2787
|
for (const capability of nodes) {
|
|
@@ -2106,9 +2795,32 @@ function findParents(nodes, target) {
|
|
|
2106
2795
|
}
|
|
2107
2796
|
return {};
|
|
2108
2797
|
}
|
|
2798
|
+
async function nextCommand(options = {}) {
|
|
2799
|
+
const cwd = options.cwd ?? process.cwd();
|
|
2800
|
+
const scanner = new Scanner(cwd, DEFAULT_CONFIG);
|
|
2801
|
+
const workItems = await scanner.scan();
|
|
2802
|
+
if (workItems.length === 0) {
|
|
2803
|
+
return EMPTY_WORK_ITEMS_MESSAGE;
|
|
2804
|
+
}
|
|
2805
|
+
const tree = await buildTree(workItems);
|
|
2806
|
+
const next = findNextWorkItem(tree);
|
|
2807
|
+
if (next === null) {
|
|
2808
|
+
return ALL_COMPLETE_MESSAGE;
|
|
2809
|
+
}
|
|
2810
|
+
const parents = findParents(tree.nodes, next);
|
|
2811
|
+
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)}`;
|
|
2812
|
+
return [
|
|
2813
|
+
NEXT_WORK_ITEM_HEADING,
|
|
2814
|
+
BLANK_LINE,
|
|
2815
|
+
pathLine,
|
|
2816
|
+
BLANK_LINE,
|
|
2817
|
+
`${INDENT}${STATUS_LABEL}: ${next.status}`,
|
|
2818
|
+
`${INDENT}${PATH_LABEL}: ${next.path}`
|
|
2819
|
+
].join("\n");
|
|
2820
|
+
}
|
|
2109
2821
|
|
|
2110
2822
|
// src/reporter/json.ts
|
|
2111
|
-
var
|
|
2823
|
+
var JSON_INDENT3 = 2;
|
|
2112
2824
|
function formatJSON(tree, config) {
|
|
2113
2825
|
const capabilities = tree.nodes.map((node) => nodeToJSON(node));
|
|
2114
2826
|
const summary = calculateSummary(tree);
|
|
@@ -2120,7 +2832,7 @@ function formatJSON(tree, config) {
|
|
|
2120
2832
|
summary,
|
|
2121
2833
|
capabilities
|
|
2122
2834
|
};
|
|
2123
|
-
return JSON.stringify(output, null,
|
|
2835
|
+
return JSON.stringify(output, null, JSON_INDENT3);
|
|
2124
2836
|
}
|
|
2125
2837
|
function nodeToJSON(node) {
|
|
2126
2838
|
const displayNumber = getDisplayNumber(node);
|
|
@@ -2302,26 +3014,32 @@ function formatStatus(status) {
|
|
|
2302
3014
|
}
|
|
2303
3015
|
|
|
2304
3016
|
// src/commands/spec/status.ts
|
|
3017
|
+
var DEFAULT_FORMAT = "text";
|
|
3018
|
+
function buildMissingDirectoryMessage() {
|
|
3019
|
+
const {
|
|
3020
|
+
root,
|
|
3021
|
+
work: {
|
|
3022
|
+
dir,
|
|
3023
|
+
statusDirs: { doing }
|
|
3024
|
+
}
|
|
3025
|
+
} = DEFAULT_CONFIG.specs;
|
|
3026
|
+
return `Directory ${root}/${dir}/${doing} not found.`;
|
|
3027
|
+
}
|
|
2305
3028
|
async function statusCommand(options = {}) {
|
|
2306
|
-
const cwd = options.cwd
|
|
2307
|
-
const format2 = options.format
|
|
3029
|
+
const cwd = options.cwd ?? process.cwd();
|
|
3030
|
+
const format2 = options.format ?? DEFAULT_FORMAT;
|
|
2308
3031
|
const scanner = new Scanner(cwd, DEFAULT_CONFIG);
|
|
2309
3032
|
let workItems;
|
|
2310
3033
|
try {
|
|
2311
3034
|
workItems = await scanner.scan();
|
|
2312
3035
|
} catch (error) {
|
|
2313
3036
|
if (error instanceof Error && error.message.includes("ENOENT")) {
|
|
2314
|
-
|
|
2315
|
-
throw new Error(
|
|
2316
|
-
`Directory ${doingPath} not found.
|
|
2317
|
-
|
|
2318
|
-
This command is for legacy specs/ projects. For CODE framework projects, check the spx/ directory for specifications.`
|
|
2319
|
-
);
|
|
3037
|
+
throw new Error(buildMissingDirectoryMessage());
|
|
2320
3038
|
}
|
|
2321
3039
|
throw error;
|
|
2322
3040
|
}
|
|
2323
3041
|
if (workItems.length === 0) {
|
|
2324
|
-
return
|
|
3042
|
+
return "No work items found in specs/work/doing";
|
|
2325
3043
|
}
|
|
2326
3044
|
const tree = await buildTree(workItems);
|
|
2327
3045
|
switch (format2) {
|
|
@@ -2332,17 +3050,16 @@ This command is for legacy specs/ projects. For CODE framework projects, check t
|
|
|
2332
3050
|
case "table":
|
|
2333
3051
|
return formatTable(tree);
|
|
2334
3052
|
case "text":
|
|
2335
|
-
default:
|
|
2336
3053
|
return formatText(tree);
|
|
2337
3054
|
}
|
|
2338
3055
|
}
|
|
2339
3056
|
|
|
2340
3057
|
// src/spec/apply/exclude/adapters/detect.ts
|
|
2341
|
-
import { join as
|
|
3058
|
+
import { join as join9 } from "path";
|
|
2342
3059
|
|
|
2343
3060
|
// src/spec/apply/exclude/constants.ts
|
|
2344
3061
|
var SPX_PREFIX = "spx/";
|
|
2345
|
-
var
|
|
3062
|
+
var NODE_SUFFIXES2 = [".outcome/", ".enabler/", ".capability/", ".feature/", ".story/"];
|
|
2346
3063
|
var COMMENT_CHAR = "#";
|
|
2347
3064
|
var EXCLUDE_FILENAME = "EXCLUDE";
|
|
2348
3065
|
|
|
@@ -2362,7 +3079,7 @@ function toPyrightPath(node) {
|
|
|
2362
3079
|
}
|
|
2363
3080
|
function isExcludedEntry(val) {
|
|
2364
3081
|
const hasPrefix = val.includes(SPX_PREFIX);
|
|
2365
|
-
const hasSuffix =
|
|
3082
|
+
const hasSuffix = NODE_SUFFIXES2.some((suffix) => val.includes(suffix));
|
|
2366
3083
|
return hasPrefix && hasSuffix;
|
|
2367
3084
|
}
|
|
2368
3085
|
|
|
@@ -2465,7 +3182,7 @@ var pythonAdapter = {
|
|
|
2465
3182
|
var ADAPTERS = [pythonAdapter];
|
|
2466
3183
|
async function detectLanguage(projectRoot, deps) {
|
|
2467
3184
|
for (const adapter of ADAPTERS) {
|
|
2468
|
-
const configPath =
|
|
3185
|
+
const configPath = join9(projectRoot, adapter.configFile);
|
|
2469
3186
|
const exists = await deps.fileExists(configPath);
|
|
2470
3187
|
if (exists) {
|
|
2471
3188
|
return adapter;
|
|
@@ -2510,15 +3227,15 @@ var APPLY_HELP = buildApplyHelp();
|
|
|
2510
3227
|
// src/spec/apply/exclude/exclude-file.ts
|
|
2511
3228
|
var TOML_UNSAFE_PATTERN = /["\\\n\r\t]/;
|
|
2512
3229
|
var PATH_TRAVERSAL_PATTERN = /(?:^|\/)\.\.(?:\/|$)/;
|
|
2513
|
-
function validateNodePath(
|
|
2514
|
-
if (
|
|
2515
|
-
return `absolute path rejected: ${
|
|
3230
|
+
function validateNodePath(path9) {
|
|
3231
|
+
if (path9.startsWith("/")) {
|
|
3232
|
+
return `absolute path rejected: ${path9}`;
|
|
2516
3233
|
}
|
|
2517
|
-
if (PATH_TRAVERSAL_PATTERN.test(
|
|
2518
|
-
return `path traversal rejected: ${
|
|
3234
|
+
if (PATH_TRAVERSAL_PATTERN.test(path9)) {
|
|
3235
|
+
return `path traversal rejected: ${path9}`;
|
|
2519
3236
|
}
|
|
2520
|
-
if (TOML_UNSAFE_PATTERN.test(
|
|
2521
|
-
return `TOML-unsafe characters rejected: ${
|
|
3237
|
+
if (TOML_UNSAFE_PATTERN.test(path9)) {
|
|
3238
|
+
return `TOML-unsafe characters rejected: ${path9}`;
|
|
2522
3239
|
}
|
|
2523
3240
|
return null;
|
|
2524
3241
|
}
|
|
@@ -2527,10 +3244,10 @@ function readExcludedNodes(content) {
|
|
|
2527
3244
|
}
|
|
2528
3245
|
|
|
2529
3246
|
// src/spec/apply/exclude/command.ts
|
|
2530
|
-
import { join as
|
|
3247
|
+
import { join as join10 } from "path";
|
|
2531
3248
|
async function applyExcludeCommand(options) {
|
|
2532
3249
|
const { cwd, deps } = options;
|
|
2533
|
-
const excludePath =
|
|
3250
|
+
const excludePath = join10(cwd, SPX_PREFIX, EXCLUDE_FILENAME);
|
|
2534
3251
|
const excludeExists = await deps.fileExists(excludePath);
|
|
2535
3252
|
if (!excludeExists) {
|
|
2536
3253
|
return { exitCode: 1, output: `error: ${excludePath} not found` };
|
|
@@ -2539,7 +3256,7 @@ async function applyExcludeCommand(options) {
|
|
|
2539
3256
|
if (!adapter) {
|
|
2540
3257
|
return { exitCode: 1, output: "error: no supported config file found (checked: pyproject.toml)" };
|
|
2541
3258
|
}
|
|
2542
|
-
const configPath =
|
|
3259
|
+
const configPath = join10(cwd, adapter.configFile);
|
|
2543
3260
|
const excludeContent = await deps.readFile(excludePath);
|
|
2544
3261
|
const configContent = await deps.readFile(configPath);
|
|
2545
3262
|
const nodes = readExcludedNodes(excludeContent);
|
|
@@ -2554,77 +3271,541 @@ async function applyExcludeCommand(options) {
|
|
|
2554
3271
|
output: `Updated ${adapter.configFile} from spx/EXCLUDE (${nodes.length} nodes).`
|
|
2555
3272
|
};
|
|
2556
3273
|
}
|
|
2557
|
-
return { exitCode: 0, output: `${adapter.configFile} is already in sync with spx/EXCLUDE.` };
|
|
3274
|
+
return { exitCode: 0, output: `${adapter.configFile} is already in sync with spx/EXCLUDE.` };
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
// src/domains/spec/index.ts
|
|
3278
|
+
var VALID_STATUS_FORMATS = [
|
|
3279
|
+
"text",
|
|
3280
|
+
"json",
|
|
3281
|
+
"markdown",
|
|
3282
|
+
"table"
|
|
3283
|
+
];
|
|
3284
|
+
function handleCommandError(error) {
|
|
3285
|
+
console.error("Error:", error instanceof Error ? error.message : String(error));
|
|
3286
|
+
process.exit(1);
|
|
3287
|
+
}
|
|
3288
|
+
function resolveStatusFormat(options) {
|
|
3289
|
+
if (options.json === true) {
|
|
3290
|
+
return "json";
|
|
3291
|
+
}
|
|
3292
|
+
if (options.format === void 0) {
|
|
3293
|
+
return "text";
|
|
3294
|
+
}
|
|
3295
|
+
if (VALID_STATUS_FORMATS.includes(options.format)) {
|
|
3296
|
+
return options.format;
|
|
3297
|
+
}
|
|
3298
|
+
throw new Error(
|
|
3299
|
+
`Invalid format "${options.format}". Must be one of: ${VALID_STATUS_FORMATS.join(", ")}`
|
|
3300
|
+
);
|
|
3301
|
+
}
|
|
3302
|
+
function registerSpecCommands(specCmd) {
|
|
3303
|
+
specCmd.command("status").description("Get project status").option("--json", "Output as JSON").option("--format <format>", "Output format (text|json|markdown|table)").action(async (options) => {
|
|
3304
|
+
try {
|
|
3305
|
+
const output = await statusCommand({
|
|
3306
|
+
cwd: process.cwd(),
|
|
3307
|
+
format: resolveStatusFormat(options)
|
|
3308
|
+
});
|
|
3309
|
+
console.log(output);
|
|
3310
|
+
} catch (error) {
|
|
3311
|
+
handleCommandError(error);
|
|
3312
|
+
}
|
|
3313
|
+
});
|
|
3314
|
+
specCmd.command("next").description("Find next work item to work on").action(async () => {
|
|
3315
|
+
try {
|
|
3316
|
+
const output = await nextCommand({ cwd: process.cwd() });
|
|
3317
|
+
console.log(output);
|
|
3318
|
+
} catch (error) {
|
|
3319
|
+
handleCommandError(error);
|
|
3320
|
+
}
|
|
3321
|
+
});
|
|
3322
|
+
specCmd.command("apply").description("Apply spec-tree state to project configuration").addHelpText("after", APPLY_HELP).action(async () => {
|
|
3323
|
+
try {
|
|
3324
|
+
const result = await applyExcludeCommand({
|
|
3325
|
+
cwd: process.cwd(),
|
|
3326
|
+
deps: {
|
|
3327
|
+
readFile: (path9) => readFile7(path9, "utf-8"),
|
|
3328
|
+
writeFile: (path9, content) => writeFile2(path9, content, "utf-8"),
|
|
3329
|
+
fileExists: async (path9) => {
|
|
3330
|
+
try {
|
|
3331
|
+
await access2(path9);
|
|
3332
|
+
return true;
|
|
3333
|
+
} catch {
|
|
3334
|
+
return false;
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
});
|
|
3339
|
+
if (result.output) console.log(result.output);
|
|
3340
|
+
process.exit(result.exitCode);
|
|
3341
|
+
} catch (error) {
|
|
3342
|
+
handleCommandError(error);
|
|
3343
|
+
}
|
|
3344
|
+
});
|
|
3345
|
+
}
|
|
3346
|
+
var specDomain = {
|
|
3347
|
+
name: "spec",
|
|
3348
|
+
description: "Manage spec workflow",
|
|
3349
|
+
register: (program2) => {
|
|
3350
|
+
const specCmd = program2.command("spec").description("Manage spec workflow");
|
|
3351
|
+
registerSpecCommands(specCmd);
|
|
3352
|
+
}
|
|
3353
|
+
};
|
|
3354
|
+
|
|
3355
|
+
// src/validation/literal/allowlist-existing.ts
|
|
3356
|
+
import { readFile as readFile10, rename as rename4, writeFile as writeFile3 } from "fs/promises";
|
|
3357
|
+
import { dirname as dirname3, join as join13 } from "path";
|
|
3358
|
+
import { parse as tomlParse, stringify as tomlStringify } from "smol-toml";
|
|
3359
|
+
import { Document, isMap, parseDocument } from "yaml";
|
|
3360
|
+
|
|
3361
|
+
// src/validation/literal/index.ts
|
|
3362
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
3363
|
+
import { isAbsolute as isAbsolute2, relative as relative3, resolve as resolve5 } from "path";
|
|
3364
|
+
|
|
3365
|
+
// src/validation/literal/detector.ts
|
|
3366
|
+
import { parse as parseTypeScript } from "@typescript-eslint/parser";
|
|
3367
|
+
import { visitorKeys as typescriptVisitorKeys } from "@typescript-eslint/visitor-keys";
|
|
3368
|
+
var REMEDIATION = {
|
|
3369
|
+
IMPORT_FROM_SOURCE: "import-from-source",
|
|
3370
|
+
EXTRACT_TO_SHARED_TEST_SUPPORT: "extract-to-shared-test-support"
|
|
3371
|
+
};
|
|
3372
|
+
var defaultVisitorKeys = typescriptVisitorKeys;
|
|
3373
|
+
var MODULE_NAMING_SKIP = {
|
|
3374
|
+
ImportDeclaration: /* @__PURE__ */ new Set(["source"]),
|
|
3375
|
+
ExportNamedDeclaration: /* @__PURE__ */ new Set(["source"]),
|
|
3376
|
+
ExportAllDeclaration: /* @__PURE__ */ new Set(["source"]),
|
|
3377
|
+
ImportExpression: /* @__PURE__ */ new Set(["source"]),
|
|
3378
|
+
TSImportType: /* @__PURE__ */ new Set(["source", "argument"]),
|
|
3379
|
+
TSExternalModuleReference: /* @__PURE__ */ new Set(["expression"])
|
|
3380
|
+
};
|
|
3381
|
+
var EMPTY_SKIP = /* @__PURE__ */ new Set();
|
|
3382
|
+
function collectLiterals(source, filename, options) {
|
|
3383
|
+
const ast = parseTypeScript(source, {
|
|
3384
|
+
loc: true,
|
|
3385
|
+
range: true,
|
|
3386
|
+
comment: false,
|
|
3387
|
+
jsx: true,
|
|
3388
|
+
ecmaVersion: "latest",
|
|
3389
|
+
sourceType: "module"
|
|
3390
|
+
});
|
|
3391
|
+
const out = [];
|
|
3392
|
+
walk(ast, filename, options, out);
|
|
3393
|
+
return out;
|
|
3394
|
+
}
|
|
3395
|
+
function walk(node, filename, options, out) {
|
|
3396
|
+
emitLiteral(node, filename, options, out);
|
|
3397
|
+
const keys = options.visitorKeys[node.type];
|
|
3398
|
+
if (!keys) {
|
|
3399
|
+
return;
|
|
3400
|
+
}
|
|
3401
|
+
const skip = MODULE_NAMING_SKIP[node.type] ?? EMPTY_SKIP;
|
|
3402
|
+
for (const key of keys) {
|
|
3403
|
+
if (skip.has(key)) continue;
|
|
3404
|
+
const child = node[key];
|
|
3405
|
+
if (Array.isArray(child)) {
|
|
3406
|
+
for (const item of child) {
|
|
3407
|
+
if (isNode(item)) walk(item, filename, options, out);
|
|
3408
|
+
}
|
|
3409
|
+
} else if (isNode(child)) {
|
|
3410
|
+
walk(child, filename, options, out);
|
|
3411
|
+
}
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
function isNode(value) {
|
|
3415
|
+
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
3416
|
+
}
|
|
3417
|
+
function emitLiteral(node, filename, options, out) {
|
|
3418
|
+
const line = node.loc?.start?.line ?? 0;
|
|
3419
|
+
if (node.type === "Literal") {
|
|
3420
|
+
if (typeof node.value === "string") {
|
|
3421
|
+
if (node.value.length >= options.minStringLength) {
|
|
3422
|
+
out.push({ kind: "string", value: node.value, loc: { file: filename, line } });
|
|
3423
|
+
}
|
|
3424
|
+
} else if (typeof node.value === "number") {
|
|
3425
|
+
const raw = typeof node.raw === "string" ? node.raw : String(node.value);
|
|
3426
|
+
if (isMeaningfulNumber(raw, options.minNumberDigits)) {
|
|
3427
|
+
out.push({ kind: "number", value: String(node.value), loc: { file: filename, line } });
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
return;
|
|
3431
|
+
}
|
|
3432
|
+
if (node.type === "TemplateElement") {
|
|
3433
|
+
const value = node.value;
|
|
3434
|
+
const cooked = value?.cooked ?? "";
|
|
3435
|
+
if (cooked.length >= options.minStringLength) {
|
|
3436
|
+
out.push({ kind: "string", value: cooked, loc: { file: filename, line } });
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3439
|
+
}
|
|
3440
|
+
function isMeaningfulNumber(raw, minDigits) {
|
|
3441
|
+
const digits = raw.replace(/[^0-9]/g, "");
|
|
3442
|
+
return digits.length >= minDigits;
|
|
3443
|
+
}
|
|
3444
|
+
function buildIndex(occurrences) {
|
|
3445
|
+
const map = /* @__PURE__ */ new Map();
|
|
3446
|
+
for (const occ of occurrences) {
|
|
3447
|
+
const key = makeKey(occ.kind, occ.value);
|
|
3448
|
+
const existing = map.get(key);
|
|
3449
|
+
if (existing) {
|
|
3450
|
+
existing.push(occ.loc);
|
|
3451
|
+
} else {
|
|
3452
|
+
map.set(key, [occ.loc]);
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
return map;
|
|
3456
|
+
}
|
|
3457
|
+
function makeKey(kind, value) {
|
|
3458
|
+
return `${kind}\0${value}`;
|
|
3459
|
+
}
|
|
3460
|
+
function splitKey(key) {
|
|
3461
|
+
const idx = key.indexOf("\0");
|
|
3462
|
+
return { kind: key.slice(0, idx), value: key.slice(idx + 1) };
|
|
3463
|
+
}
|
|
3464
|
+
function detectReuse(input) {
|
|
3465
|
+
const srcReuse = [];
|
|
3466
|
+
const testDupe = [];
|
|
3467
|
+
const testIndex = /* @__PURE__ */ new Map();
|
|
3468
|
+
for (const [file, occurrences] of input.testOccurrencesByFile) {
|
|
3469
|
+
for (const occ of occurrences) {
|
|
3470
|
+
if (input.allowlist.has(occ.value)) continue;
|
|
3471
|
+
const key = makeKey(occ.kind, occ.value);
|
|
3472
|
+
let byFile = testIndex.get(key);
|
|
3473
|
+
if (!byFile) {
|
|
3474
|
+
byFile = /* @__PURE__ */ new Map();
|
|
3475
|
+
testIndex.set(key, byFile);
|
|
3476
|
+
}
|
|
3477
|
+
const locsInFile = byFile.get(file);
|
|
3478
|
+
if (locsInFile) locsInFile.push(occ.loc);
|
|
3479
|
+
else byFile.set(file, [occ.loc]);
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
for (const [key, byFile] of testIndex) {
|
|
3483
|
+
const { kind, value } = splitKey(key);
|
|
3484
|
+
const srcLocs = input.srcIndex.get(key);
|
|
3485
|
+
const allTestLocs = [];
|
|
3486
|
+
for (const locs of byFile.values()) allTestLocs.push(...locs);
|
|
3487
|
+
if (srcLocs && srcLocs.length > 0) {
|
|
3488
|
+
for (const testLoc of allTestLocs) {
|
|
3489
|
+
srcReuse.push({
|
|
3490
|
+
test: testLoc,
|
|
3491
|
+
kind,
|
|
3492
|
+
value,
|
|
3493
|
+
src: srcLocs,
|
|
3494
|
+
remediation: REMEDIATION.IMPORT_FROM_SOURCE
|
|
3495
|
+
});
|
|
3496
|
+
}
|
|
3497
|
+
} else if (byFile.size >= 2) {
|
|
3498
|
+
for (let i = 0; i < allTestLocs.length; i += 1) {
|
|
3499
|
+
const otherTests = [...allTestLocs.slice(0, i), ...allTestLocs.slice(i + 1)];
|
|
3500
|
+
testDupe.push({
|
|
3501
|
+
test: allTestLocs[i],
|
|
3502
|
+
kind,
|
|
3503
|
+
value,
|
|
3504
|
+
otherTests,
|
|
3505
|
+
remediation: REMEDIATION.EXTRACT_TO_SHARED_TEST_SUPPORT
|
|
3506
|
+
});
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
return { srcReuse, testDupe };
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
// src/validation/literal/exclude.ts
|
|
3514
|
+
import { readFile as readFile8 } from "fs/promises";
|
|
3515
|
+
import { join as join11 } from "path";
|
|
3516
|
+
var EXCLUDE_FILENAME2 = "EXCLUDE";
|
|
3517
|
+
var SPX_DIR = "spx";
|
|
3518
|
+
async function readExcludePaths(projectRoot) {
|
|
3519
|
+
const filePath = join11(projectRoot, SPX_DIR, EXCLUDE_FILENAME2);
|
|
3520
|
+
try {
|
|
3521
|
+
const content = await readFile8(filePath, "utf8");
|
|
3522
|
+
return parseExcludeContent(content);
|
|
3523
|
+
} catch (err) {
|
|
3524
|
+
if (isNodeError(err) && err.code === "ENOENT") {
|
|
3525
|
+
return [];
|
|
3526
|
+
}
|
|
3527
|
+
throw err;
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
function isUnderExcluded(relPath, excludePaths) {
|
|
3531
|
+
for (const raw of excludePaths) {
|
|
3532
|
+
const prefix = `${SPX_DIR}/${raw}`;
|
|
3533
|
+
if (relPath === prefix || relPath.startsWith(`${prefix}/`)) {
|
|
3534
|
+
return true;
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
return false;
|
|
3538
|
+
}
|
|
3539
|
+
function parseExcludeContent(content) {
|
|
3540
|
+
return content.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
|
|
3541
|
+
}
|
|
3542
|
+
function isNodeError(err) {
|
|
3543
|
+
return typeof err === "object" && err !== null && "code" in err;
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3546
|
+
// src/validation/literal/walker.ts
|
|
3547
|
+
import { readdir as readdir6 } from "fs/promises";
|
|
3548
|
+
import { join as join12, relative as relative2 } from "path";
|
|
3549
|
+
var ARTIFACT_DIRECTORIES = /* @__PURE__ */ new Set([
|
|
3550
|
+
"node_modules",
|
|
3551
|
+
"dist",
|
|
3552
|
+
"build",
|
|
3553
|
+
".next",
|
|
3554
|
+
".source",
|
|
3555
|
+
".git",
|
|
3556
|
+
"out",
|
|
3557
|
+
"coverage"
|
|
3558
|
+
]);
|
|
3559
|
+
var TYPESCRIPT_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx"]);
|
|
3560
|
+
var DECLARATION_SUFFIX = ".d.ts";
|
|
3561
|
+
async function walkTypescriptFiles(projectRoot, excludePaths) {
|
|
3562
|
+
const out = [];
|
|
3563
|
+
await walk2(projectRoot, projectRoot, excludePaths, out);
|
|
3564
|
+
return out;
|
|
3565
|
+
}
|
|
3566
|
+
async function walk2(dir, projectRoot, excludePaths, out) {
|
|
3567
|
+
let entries;
|
|
3568
|
+
try {
|
|
3569
|
+
entries = await readdir6(dir, { withFileTypes: true });
|
|
3570
|
+
} catch (err) {
|
|
3571
|
+
if (isNodeError2(err) && (err.code === "ENOENT" || err.code === "ENOTDIR")) {
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
throw err;
|
|
3575
|
+
}
|
|
3576
|
+
for (const entry of entries) {
|
|
3577
|
+
if (entry.isDirectory()) {
|
|
3578
|
+
if (ARTIFACT_DIRECTORIES.has(entry.name)) continue;
|
|
3579
|
+
const subdir = join12(dir, entry.name);
|
|
3580
|
+
const rel = relative2(projectRoot, subdir);
|
|
3581
|
+
if (isUnderExcluded(rel, excludePaths)) continue;
|
|
3582
|
+
await walk2(subdir, projectRoot, excludePaths, out);
|
|
3583
|
+
} else if (entry.isFile()) {
|
|
3584
|
+
if (!isTypescriptSource(entry.name)) continue;
|
|
3585
|
+
out.push(join12(dir, entry.name));
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
function isTypescriptSource(name) {
|
|
3590
|
+
if (name.endsWith(DECLARATION_SUFFIX)) return false;
|
|
3591
|
+
const ext = extensionOf(name);
|
|
3592
|
+
return TYPESCRIPT_EXTENSIONS.has(ext);
|
|
3593
|
+
}
|
|
3594
|
+
function extensionOf(name) {
|
|
3595
|
+
const idx = name.lastIndexOf(".");
|
|
3596
|
+
return idx === -1 ? "" : name.slice(idx);
|
|
3597
|
+
}
|
|
3598
|
+
function isNodeError2(err) {
|
|
3599
|
+
return typeof err === "object" && err !== null && "code" in err;
|
|
3600
|
+
}
|
|
3601
|
+
function isTestFile(relPath) {
|
|
3602
|
+
return /\.test\.tsx?$/.test(relPath);
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
// src/validation/literal/index.ts
|
|
3606
|
+
async function validateLiteralReuse(input) {
|
|
3607
|
+
const config = input.config ?? literalConfigDescriptor.defaults;
|
|
3608
|
+
const excludePaths = await readExcludePaths(input.projectRoot);
|
|
3609
|
+
const candidateFiles = input.files ? input.files.map((f) => isAbsolute2(f) ? f : resolve5(input.projectRoot, f)) : await walkTypescriptFiles(input.projectRoot, excludePaths);
|
|
3610
|
+
const collectOptions = {
|
|
3611
|
+
visitorKeys: defaultVisitorKeys,
|
|
3612
|
+
minStringLength: config.minStringLength,
|
|
3613
|
+
minNumberDigits: config.minNumberDigits
|
|
3614
|
+
};
|
|
3615
|
+
const srcOccurrences = [];
|
|
3616
|
+
const testOccurrencesByFile = /* @__PURE__ */ new Map();
|
|
3617
|
+
const indexedOccurrencesByFile = /* @__PURE__ */ new Map();
|
|
3618
|
+
for (const abs of candidateFiles) {
|
|
3619
|
+
const rel = relative3(input.projectRoot, abs).split(/[\\/]/g).join("/");
|
|
3620
|
+
if (isUnderExcluded(rel, excludePaths)) continue;
|
|
3621
|
+
const content = await readSafe(abs);
|
|
3622
|
+
if (content === null) continue;
|
|
3623
|
+
const occurrences = collectLiterals(content, rel, collectOptions);
|
|
3624
|
+
indexedOccurrencesByFile.set(rel, occurrences);
|
|
3625
|
+
if (isTestFile(rel)) {
|
|
3626
|
+
testOccurrencesByFile.set(rel, occurrences);
|
|
3627
|
+
} else {
|
|
3628
|
+
srcOccurrences.push(...occurrences);
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
const srcIndex = buildIndex(srcOccurrences);
|
|
3632
|
+
const findings = detectReuse({
|
|
3633
|
+
srcIndex,
|
|
3634
|
+
testOccurrencesByFile,
|
|
3635
|
+
allowlist: resolveAllowlist(config.allowlist)
|
|
3636
|
+
});
|
|
3637
|
+
return { findings, indexedOccurrencesByFile };
|
|
3638
|
+
}
|
|
3639
|
+
async function readSafe(path9) {
|
|
3640
|
+
try {
|
|
3641
|
+
return await readFile9(path9, "utf8");
|
|
3642
|
+
} catch (err) {
|
|
3643
|
+
if (typeof err === "object" && err !== null && "code" in err) {
|
|
3644
|
+
const code = err.code;
|
|
3645
|
+
if (code === "ENOENT" || code === "EISDIR") return null;
|
|
3646
|
+
}
|
|
3647
|
+
throw err;
|
|
3648
|
+
}
|
|
2558
3649
|
}
|
|
2559
3650
|
|
|
2560
|
-
// src/
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
3651
|
+
// src/validation/literal/allowlist-existing.ts
|
|
3652
|
+
var EXIT_OK = 0;
|
|
3653
|
+
var EXIT_ERROR = 1;
|
|
3654
|
+
var DEFAULT_FORMAT2 = "yaml";
|
|
3655
|
+
var ALLOWLIST_INCLUDE_PATH = [LITERAL_SECTION, "allowlist", "include"];
|
|
3656
|
+
var TEMP_FILE_PREFIX = ".spx-allowlist-existing-";
|
|
3657
|
+
var TEMP_FILE_SUFFIX = ".tmp";
|
|
3658
|
+
var RANDOM_BASE = 36;
|
|
3659
|
+
var RANDOM_PREFIX_SLICE = 2;
|
|
3660
|
+
var RANDOM_TOKEN_LENGTH = 10;
|
|
3661
|
+
var JSON_INDENT4 = 2;
|
|
3662
|
+
var FORMAT_FILENAMES = {
|
|
3663
|
+
json: CONFIG_FILENAMES.json,
|
|
3664
|
+
yaml: CONFIG_FILENAMES.yaml,
|
|
3665
|
+
toml: CONFIG_FILENAMES.toml
|
|
3666
|
+
};
|
|
3667
|
+
var DETECTION_ORDER = ["json", "yaml", "toml"];
|
|
3668
|
+
var productionReader = {
|
|
3669
|
+
async read(projectRoot) {
|
|
3670
|
+
const detected = [];
|
|
3671
|
+
for (const format2 of DETECTION_ORDER) {
|
|
3672
|
+
const filename = FORMAT_FILENAMES[format2];
|
|
3673
|
+
const path9 = join13(projectRoot, filename);
|
|
3674
|
+
try {
|
|
3675
|
+
const raw = await readFile10(path9, "utf8");
|
|
3676
|
+
detected.push({ path: path9, format: format2, raw });
|
|
3677
|
+
} catch (error) {
|
|
3678
|
+
if (!isFileNotFound2(error)) throw error;
|
|
2577
3679
|
}
|
|
2578
|
-
const output = await statusCommand({ cwd: process.cwd(), format: format2 });
|
|
2579
|
-
console.log(output);
|
|
2580
|
-
} catch (error) {
|
|
2581
|
-
console.error(
|
|
2582
|
-
"Error:",
|
|
2583
|
-
error instanceof Error ? error.message : String(error)
|
|
2584
|
-
);
|
|
2585
|
-
process.exit(1);
|
|
2586
3680
|
}
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
const output = await nextCommand({ cwd: process.cwd() });
|
|
2591
|
-
console.log(output);
|
|
2592
|
-
} catch (error) {
|
|
2593
|
-
console.error(
|
|
2594
|
-
"Error:",
|
|
2595
|
-
error instanceof Error ? error.message : String(error)
|
|
2596
|
-
);
|
|
2597
|
-
process.exit(1);
|
|
3681
|
+
if (detected.length === 0) return { kind: "absent" };
|
|
3682
|
+
if (detected.length > 1) {
|
|
3683
|
+
return { kind: "ambiguous", detected: detected.map((file) => FORMAT_FILENAMES[file.format]) };
|
|
2598
3684
|
}
|
|
3685
|
+
return { kind: "ok", file: detected[0] };
|
|
3686
|
+
}
|
|
3687
|
+
};
|
|
3688
|
+
var productionWriter = {
|
|
3689
|
+
async write(filePath, content) {
|
|
3690
|
+
const dir = dirname3(filePath);
|
|
3691
|
+
const random = Math.random().toString(RANDOM_BASE).slice(RANDOM_PREFIX_SLICE, RANDOM_PREFIX_SLICE + RANDOM_TOKEN_LENGTH);
|
|
3692
|
+
const tmpPath = join13(dir, `${TEMP_FILE_PREFIX}${random}${TEMP_FILE_SUFFIX}`);
|
|
3693
|
+
await writeFile3(tmpPath, content, "utf8");
|
|
3694
|
+
await rename4(tmpPath, filePath);
|
|
3695
|
+
}
|
|
3696
|
+
};
|
|
3697
|
+
async function allowlistExisting(options) {
|
|
3698
|
+
const reader = options.reader ?? productionReader;
|
|
3699
|
+
const writer = options.writer ?? productionWriter;
|
|
3700
|
+
const readResult = await reader.read(options.projectRoot);
|
|
3701
|
+
if (readResult.kind === "ambiguous") {
|
|
3702
|
+
const names = readResult.detected.join(", ");
|
|
3703
|
+
return { exitCode: EXIT_ERROR, output: `multiple config files found: ${names}` };
|
|
3704
|
+
}
|
|
3705
|
+
const currentLiteralConfig = readCurrentLiteralConfig(readResult);
|
|
3706
|
+
const detection = await validateLiteralReuse({
|
|
3707
|
+
projectRoot: options.projectRoot,
|
|
3708
|
+
config: currentLiteralConfig
|
|
2599
3709
|
});
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
3710
|
+
const findingValues = collectFindingValues(detection.findings);
|
|
3711
|
+
const updatedInclude = computeUpdatedInclude(
|
|
3712
|
+
currentLiteralConfig.allowlist.include,
|
|
3713
|
+
findingValues
|
|
3714
|
+
);
|
|
3715
|
+
const target = readResult.kind === "ok" ? readResult.file : {
|
|
3716
|
+
path: join13(options.projectRoot, FORMAT_FILENAMES[DEFAULT_FORMAT2]),
|
|
3717
|
+
format: DEFAULT_FORMAT2,
|
|
3718
|
+
raw: ""
|
|
3719
|
+
};
|
|
3720
|
+
const serialized = serializeWithUpdatedInclude(target, updatedInclude);
|
|
3721
|
+
await writer.write(target.path, serialized);
|
|
3722
|
+
return { exitCode: EXIT_OK, output: "" };
|
|
3723
|
+
}
|
|
3724
|
+
function readCurrentLiteralConfig(read) {
|
|
3725
|
+
if (read.kind !== "ok") return literalConfigDescriptor.defaults;
|
|
3726
|
+
const sections = parseSections2(read.file);
|
|
3727
|
+
const literalRaw = sections[LITERAL_SECTION];
|
|
3728
|
+
if (literalRaw === void 0) return literalConfigDescriptor.defaults;
|
|
3729
|
+
const validated = literalConfigDescriptor.validate(literalRaw);
|
|
3730
|
+
return validated.ok ? validated.value : literalConfigDescriptor.defaults;
|
|
3731
|
+
}
|
|
3732
|
+
function parseSections2(file) {
|
|
3733
|
+
if (file.raw.trim() === "") return {};
|
|
3734
|
+
switch (file.format) {
|
|
3735
|
+
case "json":
|
|
3736
|
+
return JSON.parse(file.raw);
|
|
3737
|
+
case "yaml": {
|
|
3738
|
+
const parsed = parseDocument(file.raw).toJS({ maxAliasCount: 0 });
|
|
3739
|
+
return parsed ?? {};
|
|
3740
|
+
}
|
|
3741
|
+
case "toml":
|
|
3742
|
+
return tomlParse(file.raw);
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
function collectFindingValues(findings) {
|
|
3746
|
+
const values = /* @__PURE__ */ new Set();
|
|
3747
|
+
for (const finding of findings.srcReuse) values.add(finding.value);
|
|
3748
|
+
for (const finding of findings.testDupe) values.add(finding.value);
|
|
3749
|
+
return [...values];
|
|
3750
|
+
}
|
|
3751
|
+
function computeUpdatedInclude(existing, findingValues) {
|
|
3752
|
+
const existingArr = existing ?? [];
|
|
3753
|
+
const existingSet = new Set(existingArr);
|
|
3754
|
+
const additions = findingValues.filter((value) => !existingSet.has(value)).sort();
|
|
3755
|
+
return [...existingArr, ...additions];
|
|
3756
|
+
}
|
|
3757
|
+
function serializeWithUpdatedInclude(target, include) {
|
|
3758
|
+
switch (target.format) {
|
|
3759
|
+
case "yaml":
|
|
3760
|
+
return serializeYaml(target.raw, include);
|
|
3761
|
+
case "json":
|
|
3762
|
+
return serializeJson(target.raw, include);
|
|
3763
|
+
case "toml":
|
|
3764
|
+
return serializeToml(target.raw, include);
|
|
3765
|
+
}
|
|
2619
3766
|
}
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
3767
|
+
function serializeYaml(raw, include) {
|
|
3768
|
+
const doc = isEffectivelyEmpty(raw, "yaml") ? new Document({}) : parseDocument(raw);
|
|
3769
|
+
doc.setIn([...ALLOWLIST_INCLUDE_PATH], [...include]);
|
|
3770
|
+
return doc.toString();
|
|
3771
|
+
}
|
|
3772
|
+
function serializeJson(raw, include) {
|
|
3773
|
+
const obj = isEffectivelyEmpty(raw, "json") ? {} : JSON.parse(raw);
|
|
3774
|
+
setNested(obj, [...ALLOWLIST_INCLUDE_PATH], [...include]);
|
|
3775
|
+
return JSON.stringify(obj, null, JSON_INDENT4) + "\n";
|
|
3776
|
+
}
|
|
3777
|
+
function serializeToml(raw, include) {
|
|
3778
|
+
const obj = isEffectivelyEmpty(raw, "toml") ? {} : tomlParse(raw);
|
|
3779
|
+
setNested(obj, [...ALLOWLIST_INCLUDE_PATH], [...include]);
|
|
3780
|
+
return tomlStringify(obj);
|
|
3781
|
+
}
|
|
3782
|
+
function isEffectivelyEmpty(raw, format2) {
|
|
3783
|
+
if (raw.trim() === "") return true;
|
|
3784
|
+
if (format2 === "yaml") {
|
|
3785
|
+
const doc = parseDocument(raw);
|
|
3786
|
+
if (doc.contents === null) return true;
|
|
3787
|
+
if (isMap(doc.contents) && doc.contents.items.length === 0) return true;
|
|
2626
3788
|
}
|
|
2627
|
-
|
|
3789
|
+
return false;
|
|
3790
|
+
}
|
|
3791
|
+
function setNested(target, path9, value) {
|
|
3792
|
+
let cursor = target;
|
|
3793
|
+
for (let i = 0; i < path9.length - 1; i += 1) {
|
|
3794
|
+
const key = path9[i];
|
|
3795
|
+
const existing = cursor[key];
|
|
3796
|
+
if (typeof existing === "object" && existing !== null && !Array.isArray(existing)) {
|
|
3797
|
+
cursor = existing;
|
|
3798
|
+
} else {
|
|
3799
|
+
const fresh = {};
|
|
3800
|
+
cursor[key] = fresh;
|
|
3801
|
+
cursor = fresh;
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
cursor[path9[path9.length - 1]] = value;
|
|
3805
|
+
}
|
|
3806
|
+
function isFileNotFound2(error) {
|
|
3807
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
3808
|
+
}
|
|
2628
3809
|
|
|
2629
3810
|
// node_modules/.pnpm/jsonc-parser@3.3.1/node_modules/jsonc-parser/lib/esm/impl/scanner.js
|
|
2630
3811
|
function createScanner(text, ignoreTrivia = false) {
|
|
@@ -3486,15 +4667,15 @@ var ParseErrorCode;
|
|
|
3486
4667
|
})(ParseErrorCode || (ParseErrorCode = {}));
|
|
3487
4668
|
|
|
3488
4669
|
// src/validation/config/scope.ts
|
|
3489
|
-
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
3490
|
-
import { join as
|
|
4670
|
+
import { existsSync as existsSync2, readdirSync, readFileSync } from "fs";
|
|
4671
|
+
import { join as join14 } from "path";
|
|
3491
4672
|
var TSCONFIG_FILES = {
|
|
3492
4673
|
full: "tsconfig.json",
|
|
3493
4674
|
production: "tsconfig.production.json"
|
|
3494
4675
|
};
|
|
3495
4676
|
var defaultScopeDeps = {
|
|
3496
4677
|
readFileSync,
|
|
3497
|
-
existsSync,
|
|
4678
|
+
existsSync: existsSync2,
|
|
3498
4679
|
readdirSync
|
|
3499
4680
|
};
|
|
3500
4681
|
function parseTypeScriptConfig(configPath, deps = defaultScopeDeps) {
|
|
@@ -3534,7 +4715,7 @@ function hasTypeScriptFilesRecursive(dirPath, maxDepth = 2, deps = defaultScopeD
|
|
|
3534
4715
|
if (hasDirectTsFiles) return true;
|
|
3535
4716
|
const subdirs = items.filter((item) => item.isDirectory() && !item.name.startsWith("."));
|
|
3536
4717
|
for (const subdir of subdirs.slice(0, 5)) {
|
|
3537
|
-
if (hasTypeScriptFilesRecursive(
|
|
4718
|
+
if (hasTypeScriptFilesRecursive(join14(dirPath, subdir.name), maxDepth - 1, deps)) {
|
|
3538
4719
|
return true;
|
|
3539
4720
|
}
|
|
3540
4721
|
}
|
|
@@ -3595,7 +4776,7 @@ function getTypeScriptScope(scope, deps = defaultScopeDeps) {
|
|
|
3595
4776
|
}
|
|
3596
4777
|
|
|
3597
4778
|
// src/validation/discovery/tool-finder.ts
|
|
3598
|
-
import { execSync } from "child_process";
|
|
4779
|
+
import { execSync as execSync2 } from "child_process";
|
|
3599
4780
|
import fs6 from "fs";
|
|
3600
4781
|
import { createRequire } from "module";
|
|
3601
4782
|
import path7 from "path";
|
|
@@ -3643,7 +4824,7 @@ var defaultToolDiscoveryDeps = {
|
|
|
3643
4824
|
existsSync: fs6.existsSync,
|
|
3644
4825
|
whichSync: (tool) => {
|
|
3645
4826
|
try {
|
|
3646
|
-
const result =
|
|
4827
|
+
const result = execSync2(`which ${tool}`, {
|
|
3647
4828
|
encoding: "utf-8",
|
|
3648
4829
|
stdio: ["pipe", "pipe", "pipe"]
|
|
3649
4830
|
});
|
|
@@ -3703,44 +4884,32 @@ function formatSkipMessage(stepName, result) {
|
|
|
3703
4884
|
return TOOL_DISCOVERY.MESSAGES.SKIP_FORMAT(stepName, result.notFound.tool);
|
|
3704
4885
|
}
|
|
3705
4886
|
|
|
3706
|
-
// src/validation/
|
|
3707
|
-
import
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
var
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
var
|
|
3717
|
-
|
|
3718
|
-
ESLINT: "ESLint",
|
|
3719
|
-
TYPESCRIPT: "TypeScript",
|
|
3720
|
-
KNIP: "Unused Code"
|
|
3721
|
-
};
|
|
3722
|
-
var STEP_DESCRIPTIONS = {
|
|
3723
|
-
CIRCULAR: "Checking for circular dependencies",
|
|
3724
|
-
ESLINT: "Validating ESLint compliance",
|
|
3725
|
-
TYPESCRIPT: "Validating TypeScript",
|
|
3726
|
-
KNIP: "Detecting unused exports, dependencies, and files"
|
|
3727
|
-
};
|
|
3728
|
-
var CACHE_PATHS = {
|
|
3729
|
-
ESLINT: "dist/.eslintcache",
|
|
3730
|
-
TIMINGS: "dist/.validation-timings.json"
|
|
3731
|
-
};
|
|
3732
|
-
var VALIDATION_KEYS = {
|
|
3733
|
-
TYPESCRIPT: "TYPESCRIPT",
|
|
3734
|
-
ESLINT: "ESLINT",
|
|
3735
|
-
KNIP: "KNIP"
|
|
3736
|
-
};
|
|
3737
|
-
var VALIDATION_DEFAULTS = {
|
|
3738
|
-
[VALIDATION_KEYS.TYPESCRIPT]: true,
|
|
3739
|
-
[VALIDATION_KEYS.ESLINT]: true,
|
|
3740
|
-
[VALIDATION_KEYS.KNIP]: false
|
|
4887
|
+
// src/validation/discovery/language-finder.ts
|
|
4888
|
+
import fs7 from "fs";
|
|
4889
|
+
import path8 from "path";
|
|
4890
|
+
var TYPESCRIPT_MARKER = "tsconfig.json";
|
|
4891
|
+
var ESLINT_CONFIG_FILES = [
|
|
4892
|
+
"eslint.config.ts",
|
|
4893
|
+
"eslint.config.js",
|
|
4894
|
+
"eslint.config.mjs",
|
|
4895
|
+
"eslint.config.cjs"
|
|
4896
|
+
];
|
|
4897
|
+
var defaultLanguageDetectionDeps = {
|
|
4898
|
+
existsSync: fs7.existsSync
|
|
3741
4899
|
};
|
|
4900
|
+
function detectTypeScript(projectRoot, deps = defaultLanguageDetectionDeps) {
|
|
4901
|
+
const present = deps.existsSync(path8.join(projectRoot, TYPESCRIPT_MARKER));
|
|
4902
|
+
if (!present) {
|
|
4903
|
+
return { present: false };
|
|
4904
|
+
}
|
|
4905
|
+
const eslintConfigFile = ESLINT_CONFIG_FILES.find(
|
|
4906
|
+
(configFile) => deps.existsSync(path8.join(projectRoot, configFile))
|
|
4907
|
+
);
|
|
4908
|
+
return eslintConfigFile === void 0 ? { present: true } : { present: true, eslintConfigFile };
|
|
4909
|
+
}
|
|
3742
4910
|
|
|
3743
4911
|
// src/validation/steps/circular.ts
|
|
4912
|
+
import madge from "madge";
|
|
3744
4913
|
var defaultCircularDeps = {
|
|
3745
4914
|
madge
|
|
3746
4915
|
};
|
|
@@ -3776,34 +4945,20 @@ async function validateCircularDependencies(scope, typescriptScope, deps = defau
|
|
|
3776
4945
|
return { success: false, error: errorMessage };
|
|
3777
4946
|
}
|
|
3778
4947
|
}
|
|
3779
|
-
var circularDependencyStep = {
|
|
3780
|
-
id: STEP_IDS.CIRCULAR,
|
|
3781
|
-
name: STEP_NAMES.CIRCULAR,
|
|
3782
|
-
description: STEP_DESCRIPTIONS.CIRCULAR,
|
|
3783
|
-
enabled: (context) => !context.isFileSpecificMode && context.enabledValidations[VALIDATION_KEYS.TYPESCRIPT] === true,
|
|
3784
|
-
execute: async (context) => {
|
|
3785
|
-
const startTime = performance.now();
|
|
3786
|
-
try {
|
|
3787
|
-
const result = await validateCircularDependencies(context.scope, context.scopeConfig);
|
|
3788
|
-
return {
|
|
3789
|
-
success: result.success,
|
|
3790
|
-
error: result.error,
|
|
3791
|
-
duration: performance.now() - startTime
|
|
3792
|
-
};
|
|
3793
|
-
} catch (error) {
|
|
3794
|
-
return {
|
|
3795
|
-
success: false,
|
|
3796
|
-
error: error instanceof Error ? error.message : String(error),
|
|
3797
|
-
duration: performance.now() - startTime
|
|
3798
|
-
};
|
|
3799
|
-
}
|
|
3800
|
-
}
|
|
3801
|
-
};
|
|
3802
4948
|
|
|
3803
4949
|
// src/commands/validation/circular.ts
|
|
4950
|
+
var TYPESCRIPT_ABSENT_MESSAGE = "\u23ED Skipping Circular dependencies (TypeScript not detected in project)";
|
|
3804
4951
|
async function circularCommand(options) {
|
|
3805
4952
|
const { cwd, quiet } = options;
|
|
3806
4953
|
const startTime = Date.now();
|
|
4954
|
+
const tsDetection = detectTypeScript(cwd);
|
|
4955
|
+
if (!tsDetection.present) {
|
|
4956
|
+
return {
|
|
4957
|
+
exitCode: 0,
|
|
4958
|
+
output: quiet ? "" : TYPESCRIPT_ABSENT_MESSAGE,
|
|
4959
|
+
durationMs: Date.now() - startTime
|
|
4960
|
+
};
|
|
4961
|
+
}
|
|
3807
4962
|
const toolResult = await discoverTool("madge", { projectRoot: cwd });
|
|
3808
4963
|
if (!toolResult.found) {
|
|
3809
4964
|
const skipMessage = formatSkipMessage("circular dependency check", toolResult);
|
|
@@ -3864,20 +5019,27 @@ var EXECUTION_MODES = {
|
|
|
3864
5019
|
WRITE: "write"
|
|
3865
5020
|
};
|
|
3866
5021
|
|
|
5022
|
+
// src/validation/steps/constants.ts
|
|
5023
|
+
var CACHE_PATHS = {
|
|
5024
|
+
ESLINT: "dist/.eslintcache",
|
|
5025
|
+
TIMINGS: "dist/.validation-timings.json"
|
|
5026
|
+
};
|
|
5027
|
+
|
|
3867
5028
|
// src/validation/steps/eslint.ts
|
|
3868
5029
|
var defaultEslintProcessRunner = { spawn };
|
|
5030
|
+
var DEFAULT_ESLINT_CONFIG_FILE = "eslint.config.ts";
|
|
3869
5031
|
function buildEslintArgs(context) {
|
|
3870
|
-
const { validatedFiles, mode, cacheFile } = context;
|
|
5032
|
+
const { validatedFiles, mode, cacheFile, configFile = DEFAULT_ESLINT_CONFIG_FILE } = context;
|
|
3871
5033
|
const fixArg = mode === EXECUTION_MODES.WRITE ? ["--fix"] : [];
|
|
3872
5034
|
const cacheArgs = ["--cache", "--cache-location", cacheFile];
|
|
3873
5035
|
if (validatedFiles && validatedFiles.length > 0) {
|
|
3874
|
-
return ["eslint", "--config",
|
|
5036
|
+
return ["eslint", "--config", configFile, ...cacheArgs, ...fixArg, "--", ...validatedFiles];
|
|
3875
5037
|
}
|
|
3876
|
-
return ["eslint", ".", "--config",
|
|
5038
|
+
return ["eslint", ".", "--config", configFile, ...cacheArgs, ...fixArg];
|
|
3877
5039
|
}
|
|
3878
5040
|
async function validateESLint(context, runner = defaultEslintProcessRunner) {
|
|
3879
|
-
const { scope, validatedFiles, mode } = context;
|
|
3880
|
-
return new Promise((
|
|
5041
|
+
const { projectRoot, scope, validatedFiles, mode, eslintConfigFile } = context;
|
|
5042
|
+
return new Promise((resolve6) => {
|
|
3881
5043
|
if (!validatedFiles || validatedFiles.length === 0) {
|
|
3882
5044
|
if (scope === VALIDATION_SCOPES.PRODUCTION) {
|
|
3883
5045
|
process.env.ESLINT_PRODUCTION_ONLY = "1";
|
|
@@ -3888,57 +5050,35 @@ async function validateESLint(context, runner = defaultEslintProcessRunner) {
|
|
|
3888
5050
|
const eslintArgs = buildEslintArgs({
|
|
3889
5051
|
validatedFiles,
|
|
3890
5052
|
mode,
|
|
3891
|
-
cacheFile: CACHE_PATHS.ESLINT
|
|
5053
|
+
cacheFile: CACHE_PATHS.ESLINT,
|
|
5054
|
+
configFile: eslintConfigFile
|
|
3892
5055
|
});
|
|
3893
5056
|
const eslintProcess = runner.spawn("npx", eslintArgs, {
|
|
3894
|
-
cwd:
|
|
5057
|
+
cwd: projectRoot,
|
|
3895
5058
|
stdio: "inherit"
|
|
3896
5059
|
});
|
|
3897
5060
|
eslintProcess.on("close", (code) => {
|
|
3898
5061
|
if (code === 0) {
|
|
3899
|
-
|
|
5062
|
+
resolve6({ success: true });
|
|
3900
5063
|
} else {
|
|
3901
|
-
|
|
5064
|
+
resolve6({ success: false, error: `ESLint exited with code ${code}` });
|
|
3902
5065
|
}
|
|
3903
5066
|
});
|
|
3904
5067
|
eslintProcess.on("error", (error) => {
|
|
3905
|
-
|
|
5068
|
+
resolve6({ success: false, error: error.message });
|
|
3906
5069
|
});
|
|
3907
5070
|
});
|
|
3908
5071
|
}
|
|
3909
|
-
function validationEnabled(envVarKey,
|
|
5072
|
+
function validationEnabled(envVarKey, defaults2 = {}) {
|
|
3910
5073
|
const envVar = `${envVarKey}_VALIDATION_ENABLED`;
|
|
3911
5074
|
const explicitlyDisabled = process.env[envVar] === "0";
|
|
3912
5075
|
const explicitlyEnabled = process.env[envVar] === "1";
|
|
3913
|
-
const defaultValue =
|
|
5076
|
+
const defaultValue = defaults2[envVarKey] ?? true;
|
|
3914
5077
|
if (defaultValue) {
|
|
3915
5078
|
return !explicitlyDisabled;
|
|
3916
5079
|
}
|
|
3917
5080
|
return explicitlyEnabled;
|
|
3918
5081
|
}
|
|
3919
|
-
var eslintStep = {
|
|
3920
|
-
id: STEP_IDS.ESLINT,
|
|
3921
|
-
name: STEP_NAMES.ESLINT,
|
|
3922
|
-
description: STEP_DESCRIPTIONS.ESLINT,
|
|
3923
|
-
enabled: (context) => context.enabledValidations[VALIDATION_KEYS.ESLINT] === true && validationEnabled(VALIDATION_KEYS.ESLINT),
|
|
3924
|
-
execute: async (context) => {
|
|
3925
|
-
const startTime = performance.now();
|
|
3926
|
-
try {
|
|
3927
|
-
const result = await validateESLint(context);
|
|
3928
|
-
return {
|
|
3929
|
-
success: result.success,
|
|
3930
|
-
error: result.error,
|
|
3931
|
-
duration: performance.now() - startTime
|
|
3932
|
-
};
|
|
3933
|
-
} catch (error) {
|
|
3934
|
-
return {
|
|
3935
|
-
success: false,
|
|
3936
|
-
error: error instanceof Error ? error.message : String(error),
|
|
3937
|
-
duration: performance.now() - startTime
|
|
3938
|
-
};
|
|
3939
|
-
}
|
|
3940
|
-
}
|
|
3941
|
-
};
|
|
3942
5082
|
|
|
3943
5083
|
// src/validation/steps/knip.ts
|
|
3944
5084
|
import { spawn as spawn2 } from "child_process";
|
|
@@ -3949,7 +5089,7 @@ async function validateKnip(typescriptScope, runner = defaultKnipProcessRunner)
|
|
|
3949
5089
|
if (analyzeDirectories.length === 0) {
|
|
3950
5090
|
return { success: true };
|
|
3951
5091
|
}
|
|
3952
|
-
return new Promise((
|
|
5092
|
+
return new Promise((resolve6) => {
|
|
3953
5093
|
const knipProcess = runner.spawn("npx", ["knip"], {
|
|
3954
5094
|
cwd: process.cwd(),
|
|
3955
5095
|
stdio: "pipe"
|
|
@@ -3964,17 +5104,17 @@ async function validateKnip(typescriptScope, runner = defaultKnipProcessRunner)
|
|
|
3964
5104
|
});
|
|
3965
5105
|
knipProcess.on("close", (code) => {
|
|
3966
5106
|
if (code === 0) {
|
|
3967
|
-
|
|
5107
|
+
resolve6({ success: true });
|
|
3968
5108
|
} else {
|
|
3969
5109
|
const errorOutput = knipOutput || knipError || "Unused code detected";
|
|
3970
|
-
|
|
5110
|
+
resolve6({
|
|
3971
5111
|
success: false,
|
|
3972
5112
|
error: errorOutput
|
|
3973
5113
|
});
|
|
3974
5114
|
}
|
|
3975
5115
|
});
|
|
3976
5116
|
knipProcess.on("error", (error) => {
|
|
3977
|
-
|
|
5117
|
+
resolve6({ success: false, error: error.message });
|
|
3978
5118
|
});
|
|
3979
5119
|
});
|
|
3980
5120
|
} catch (error) {
|
|
@@ -3982,29 +5122,6 @@ async function validateKnip(typescriptScope, runner = defaultKnipProcessRunner)
|
|
|
3982
5122
|
return { success: false, error: errorMessage };
|
|
3983
5123
|
}
|
|
3984
5124
|
}
|
|
3985
|
-
var knipStep = {
|
|
3986
|
-
id: STEP_IDS.KNIP,
|
|
3987
|
-
name: STEP_NAMES.KNIP,
|
|
3988
|
-
description: STEP_DESCRIPTIONS.KNIP,
|
|
3989
|
-
enabled: (context) => context.enabledValidations[VALIDATION_KEYS.KNIP] === true && validationEnabled(VALIDATION_KEYS.KNIP, VALIDATION_DEFAULTS) && !context.isFileSpecificMode,
|
|
3990
|
-
execute: async (context) => {
|
|
3991
|
-
const startTime = performance.now();
|
|
3992
|
-
try {
|
|
3993
|
-
const result = await validateKnip(context.scopeConfig);
|
|
3994
|
-
return {
|
|
3995
|
-
success: result.success,
|
|
3996
|
-
error: result.error,
|
|
3997
|
-
duration: performance.now() - startTime
|
|
3998
|
-
};
|
|
3999
|
-
} catch (error) {
|
|
4000
|
-
return {
|
|
4001
|
-
success: false,
|
|
4002
|
-
error: error instanceof Error ? error.message : String(error),
|
|
4003
|
-
duration: performance.now() - startTime
|
|
4004
|
-
};
|
|
4005
|
-
}
|
|
4006
|
-
}
|
|
4007
|
-
};
|
|
4008
5125
|
|
|
4009
5126
|
// src/commands/validation/knip.ts
|
|
4010
5127
|
async function knipCommand(options) {
|
|
@@ -4032,9 +5149,26 @@ async function knipCommand(options) {
|
|
|
4032
5149
|
}
|
|
4033
5150
|
|
|
4034
5151
|
// src/commands/validation/lint.ts
|
|
5152
|
+
var TYPESCRIPT_ABSENT_MESSAGE2 = "\u23ED Skipping ESLint (TypeScript not detected in project)";
|
|
5153
|
+
var MISSING_CONFIG_MESSAGE = "ESLint config not found: project has tsconfig.json but no eslint.config.{ts,js,mjs,cjs}";
|
|
4035
5154
|
async function lintCommand(options) {
|
|
4036
5155
|
const { cwd, scope = "full", files, fix, quiet } = options;
|
|
4037
5156
|
const startTime = Date.now();
|
|
5157
|
+
const tsDetection = detectTypeScript(cwd);
|
|
5158
|
+
if (!tsDetection.present) {
|
|
5159
|
+
return {
|
|
5160
|
+
exitCode: 0,
|
|
5161
|
+
output: quiet ? "" : TYPESCRIPT_ABSENT_MESSAGE2,
|
|
5162
|
+
durationMs: Date.now() - startTime
|
|
5163
|
+
};
|
|
5164
|
+
}
|
|
5165
|
+
if (tsDetection.eslintConfigFile === void 0) {
|
|
5166
|
+
return {
|
|
5167
|
+
exitCode: 1,
|
|
5168
|
+
output: MISSING_CONFIG_MESSAGE,
|
|
5169
|
+
durationMs: Date.now() - startTime
|
|
5170
|
+
};
|
|
5171
|
+
}
|
|
4038
5172
|
const toolResult = await discoverTool("eslint", { projectRoot: cwd });
|
|
4039
5173
|
if (!toolResult.found) {
|
|
4040
5174
|
const skipMessage = formatSkipMessage("ESLint", toolResult);
|
|
@@ -4048,7 +5182,8 @@ async function lintCommand(options) {
|
|
|
4048
5182
|
mode: fix ? "write" : "read",
|
|
4049
5183
|
enabledValidations: { ESLINT: true },
|
|
4050
5184
|
validatedFiles: files,
|
|
4051
|
-
isFileSpecificMode: Boolean(files && files.length > 0)
|
|
5185
|
+
isFileSpecificMode: Boolean(files && files.length > 0),
|
|
5186
|
+
eslintConfigFile: tsDetection.eslintConfigFile
|
|
4052
5187
|
};
|
|
4053
5188
|
const result = await validateESLint(context);
|
|
4054
5189
|
const durationMs = Date.now() - startTime;
|
|
@@ -4061,18 +5196,222 @@ async function lintCommand(options) {
|
|
|
4061
5196
|
}
|
|
4062
5197
|
}
|
|
4063
5198
|
|
|
5199
|
+
// src/commands/validation/literal.ts
|
|
5200
|
+
var EXIT_OK2 = 0;
|
|
5201
|
+
var EXIT_FINDINGS = 1;
|
|
5202
|
+
var EXIT_CONFIG_ERROR = 2;
|
|
5203
|
+
var TYPESCRIPT_ABSENT_MESSAGE3 = "\u23ED Skipping Literal (TypeScript not detected in project)";
|
|
5204
|
+
var DISABLED_MESSAGE = "\u23ED Skipping Literal (LITERAL_VALIDATION_ENABLED=0)";
|
|
5205
|
+
async function literalCommand(options) {
|
|
5206
|
+
const start = Date.now();
|
|
5207
|
+
const tsDetection = detectTypeScript(options.cwd);
|
|
5208
|
+
if (!tsDetection.present) {
|
|
5209
|
+
return {
|
|
5210
|
+
exitCode: EXIT_OK2,
|
|
5211
|
+
output: options.quiet ? "" : TYPESCRIPT_ABSENT_MESSAGE3,
|
|
5212
|
+
durationMs: Date.now() - start
|
|
5213
|
+
};
|
|
5214
|
+
}
|
|
5215
|
+
if (!validationEnabled("LITERAL")) {
|
|
5216
|
+
return {
|
|
5217
|
+
exitCode: EXIT_OK2,
|
|
5218
|
+
output: options.quiet ? "" : DISABLED_MESSAGE,
|
|
5219
|
+
durationMs: Date.now() - start
|
|
5220
|
+
};
|
|
5221
|
+
}
|
|
5222
|
+
let resolvedConfig;
|
|
5223
|
+
if (options.config !== void 0) {
|
|
5224
|
+
resolvedConfig = options.config;
|
|
5225
|
+
} else {
|
|
5226
|
+
const loaded = await resolveConfig(options.cwd, [literalConfigDescriptor]);
|
|
5227
|
+
if (!loaded.ok) {
|
|
5228
|
+
return {
|
|
5229
|
+
exitCode: EXIT_CONFIG_ERROR,
|
|
5230
|
+
output: `Literal: \u2717 config error \u2014 ${loaded.error}`,
|
|
5231
|
+
durationMs: Date.now() - start
|
|
5232
|
+
};
|
|
5233
|
+
}
|
|
5234
|
+
resolvedConfig = loaded.value[literalConfigDescriptor.section];
|
|
5235
|
+
}
|
|
5236
|
+
const result = await validateLiteralReuse({
|
|
5237
|
+
projectRoot: options.cwd,
|
|
5238
|
+
files: options.files,
|
|
5239
|
+
config: resolvedConfig
|
|
5240
|
+
});
|
|
5241
|
+
const totalFindings = result.findings.srcReuse.length + result.findings.testDupe.length;
|
|
5242
|
+
const exitCode = totalFindings === 0 ? EXIT_OK2 : EXIT_FINDINGS;
|
|
5243
|
+
const output = options.json ? JSON.stringify(result.findings) : options.quiet ? "" : totalFindings === 0 ? "Literal: \u2713 No findings" : `Literal: \u2717 ${totalFindings} finding${totalFindings === 1 ? "" : "s"}
|
|
5244
|
+
${formatText2(result.findings)}`;
|
|
5245
|
+
return { exitCode, output, durationMs: Date.now() - start };
|
|
5246
|
+
}
|
|
5247
|
+
function formatText2(findings) {
|
|
5248
|
+
const lines = [];
|
|
5249
|
+
for (const f of findings.srcReuse) {
|
|
5250
|
+
lines.push(
|
|
5251
|
+
`[reuse] ${formatLoc(f.test)}: ${f.kind} literal "${f.value}" also in ${f.src.map(formatLoc).join(", ")} \u2014 import from source`
|
|
5252
|
+
);
|
|
5253
|
+
}
|
|
5254
|
+
for (const f of findings.testDupe) {
|
|
5255
|
+
lines.push(
|
|
5256
|
+
`[dupe] ${formatLoc(f.test)}: ${f.kind} literal "${f.value}" also in ${f.otherTests.map(formatLoc).join(", ")} \u2014 extract to shared test support`
|
|
5257
|
+
);
|
|
5258
|
+
}
|
|
5259
|
+
return lines.join("\n");
|
|
5260
|
+
}
|
|
5261
|
+
function formatLoc(loc) {
|
|
5262
|
+
return `${loc.file}:${loc.line}`;
|
|
5263
|
+
}
|
|
5264
|
+
|
|
5265
|
+
// src/validation/steps/markdown.ts
|
|
5266
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
5267
|
+
import { basename, join as join15 } from "path";
|
|
5268
|
+
import { main as markdownlintMain } from "markdownlint-cli2";
|
|
5269
|
+
import relativeLinksRule from "markdownlint-rule-relative-links";
|
|
5270
|
+
var DEFAULT_DIRECTORY_NAMES = ["spx", "docs"];
|
|
5271
|
+
var ENABLED_RULES = {
|
|
5272
|
+
MD001: true,
|
|
5273
|
+
MD003: true,
|
|
5274
|
+
MD009: true,
|
|
5275
|
+
MD010: true,
|
|
5276
|
+
MD025: true,
|
|
5277
|
+
MD047: true
|
|
5278
|
+
};
|
|
5279
|
+
var MD024_DISABLED_DIRECTORIES = ["docs"];
|
|
5280
|
+
var ERROR_LINE_PATTERN = /^(.+?):(\d+)(?::\d+)?\s+(.+)$/;
|
|
5281
|
+
function buildMarkdownlintConfig(directoryName) {
|
|
5282
|
+
const md024Disabled = MD024_DISABLED_DIRECTORIES.includes(
|
|
5283
|
+
directoryName
|
|
5284
|
+
);
|
|
5285
|
+
return {
|
|
5286
|
+
default: false,
|
|
5287
|
+
...ENABLED_RULES,
|
|
5288
|
+
MD024: md024Disabled ? false : { siblings_only: true },
|
|
5289
|
+
customRules: [relativeLinksRule]
|
|
5290
|
+
};
|
|
5291
|
+
}
|
|
5292
|
+
function getDefaultDirectories(projectRoot) {
|
|
5293
|
+
return DEFAULT_DIRECTORY_NAMES.map((name) => join15(projectRoot, name)).filter((dir) => existsSync3(dir));
|
|
5294
|
+
}
|
|
5295
|
+
function getExcludeGlobs(spxDir) {
|
|
5296
|
+
const excludePath = join15(spxDir, EXCLUDE_FILENAME);
|
|
5297
|
+
if (!existsSync3(excludePath)) {
|
|
5298
|
+
return [];
|
|
5299
|
+
}
|
|
5300
|
+
const content = readFileSync2(excludePath, "utf-8");
|
|
5301
|
+
const nodes = readExcludedNodes(content);
|
|
5302
|
+
return nodes.map((node) => `${node}/**`);
|
|
5303
|
+
}
|
|
5304
|
+
var DATA_URI_PATTERN = /\bdata:/;
|
|
5305
|
+
function parseErrorLine(line) {
|
|
5306
|
+
if (DATA_URI_PATTERN.test(line)) {
|
|
5307
|
+
return null;
|
|
5308
|
+
}
|
|
5309
|
+
const match = ERROR_LINE_PATTERN.exec(line);
|
|
5310
|
+
if (!match) {
|
|
5311
|
+
return null;
|
|
5312
|
+
}
|
|
5313
|
+
const [, file, lineStr, detail] = match;
|
|
5314
|
+
return {
|
|
5315
|
+
file,
|
|
5316
|
+
line: parseInt(lineStr, 10),
|
|
5317
|
+
detail
|
|
5318
|
+
};
|
|
5319
|
+
}
|
|
5320
|
+
async function validateMarkdown(options) {
|
|
5321
|
+
const { directories, projectRoot } = options;
|
|
5322
|
+
const errors = [];
|
|
5323
|
+
for (const directory of directories) {
|
|
5324
|
+
const dirName = basename(directory);
|
|
5325
|
+
const config = buildMarkdownlintConfig(dirName);
|
|
5326
|
+
const excludeGlobs = getExcludeGlobs(directory);
|
|
5327
|
+
const dirErrors = await validateDirectory(directory, config, projectRoot, excludeGlobs);
|
|
5328
|
+
errors.push(...dirErrors);
|
|
5329
|
+
}
|
|
5330
|
+
return {
|
|
5331
|
+
success: errors.length === 0,
|
|
5332
|
+
errors
|
|
5333
|
+
};
|
|
5334
|
+
}
|
|
5335
|
+
async function validateDirectory(directory, config, projectRoot, ignoreGlobs = []) {
|
|
5336
|
+
const errors = [];
|
|
5337
|
+
const { customRules, ...markdownlintConfig } = config;
|
|
5338
|
+
const optionsOverride = {
|
|
5339
|
+
config: {
|
|
5340
|
+
...markdownlintConfig,
|
|
5341
|
+
"relative-links": projectRoot ? { root_path: projectRoot } : true
|
|
5342
|
+
},
|
|
5343
|
+
customRules,
|
|
5344
|
+
noProgress: true,
|
|
5345
|
+
noBanner: true,
|
|
5346
|
+
...ignoreGlobs.length > 0 ? { ignores: ignoreGlobs } : {}
|
|
5347
|
+
};
|
|
5348
|
+
await markdownlintMain({
|
|
5349
|
+
directory,
|
|
5350
|
+
argv: ["**/*.md"],
|
|
5351
|
+
optionsOverride,
|
|
5352
|
+
noImport: true,
|
|
5353
|
+
logMessage: () => {
|
|
5354
|
+
},
|
|
5355
|
+
logError: (message) => {
|
|
5356
|
+
const parsed = parseErrorLine(message);
|
|
5357
|
+
if (parsed) {
|
|
5358
|
+
errors.push({
|
|
5359
|
+
...parsed,
|
|
5360
|
+
file: join15(directory, parsed.file)
|
|
5361
|
+
});
|
|
5362
|
+
}
|
|
5363
|
+
}
|
|
5364
|
+
});
|
|
5365
|
+
return errors;
|
|
5366
|
+
}
|
|
5367
|
+
|
|
5368
|
+
// src/commands/validation/markdown.ts
|
|
5369
|
+
var MARKDOWN_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".markdown"]);
|
|
5370
|
+
function isMarkdownOrDirectory(path9) {
|
|
5371
|
+
const lastDot = path9.lastIndexOf(".");
|
|
5372
|
+
if (lastDot < 0) return true;
|
|
5373
|
+
const ext = path9.slice(lastDot).toLowerCase();
|
|
5374
|
+
return MARKDOWN_EXTENSIONS.has(ext);
|
|
5375
|
+
}
|
|
5376
|
+
async function markdownCommand(options) {
|
|
5377
|
+
const { cwd, files, quiet } = options;
|
|
5378
|
+
const startTime = Date.now();
|
|
5379
|
+
const markdownScopedFiles = files?.filter(isMarkdownOrDirectory);
|
|
5380
|
+
const directories = markdownScopedFiles && markdownScopedFiles.length > 0 ? markdownScopedFiles : files && files.length > 0 ? [] : getDefaultDirectories(cwd);
|
|
5381
|
+
if (directories.length === 0) {
|
|
5382
|
+
const reason = files && files.length > 0 ? "no markdown files in --files scope" : "no spx/ or docs/ directories found";
|
|
5383
|
+
const output = quiet ? "" : `Markdown: skipped (${reason})`;
|
|
5384
|
+
return { exitCode: 0, output, durationMs: Date.now() - startTime };
|
|
5385
|
+
}
|
|
5386
|
+
const result = await validateMarkdown({
|
|
5387
|
+
directories,
|
|
5388
|
+
projectRoot: cwd
|
|
5389
|
+
});
|
|
5390
|
+
const durationMs = Date.now() - startTime;
|
|
5391
|
+
if (result.success) {
|
|
5392
|
+
const output = quiet ? "" : "Markdown: No issues found";
|
|
5393
|
+
return { exitCode: 0, output, durationMs };
|
|
5394
|
+
} else {
|
|
5395
|
+
const errorLines = result.errors.map(
|
|
5396
|
+
(error) => ` ${error.file}:${error.line} ${error.detail}`
|
|
5397
|
+
);
|
|
5398
|
+
const output = [`Markdown: ${result.errors.length} error(s) found`, ...errorLines].join("\n");
|
|
5399
|
+
return { exitCode: 1, output, durationMs };
|
|
5400
|
+
}
|
|
5401
|
+
}
|
|
5402
|
+
|
|
4064
5403
|
// src/validation/steps/typescript.ts
|
|
4065
5404
|
import { spawn as spawn3 } from "child_process";
|
|
4066
|
-
import { existsSync as
|
|
5405
|
+
import { existsSync as existsSync4, mkdirSync, rmSync, writeFileSync } from "fs";
|
|
4067
5406
|
import { mkdtemp } from "fs/promises";
|
|
4068
5407
|
import { tmpdir } from "os";
|
|
4069
|
-
import { isAbsolute as
|
|
5408
|
+
import { isAbsolute as isAbsolute3, join as join16 } from "path";
|
|
4070
5409
|
var defaultTypeScriptProcessRunner = { spawn: spawn3 };
|
|
4071
5410
|
var defaultTypeScriptDeps = {
|
|
4072
5411
|
mkdtemp,
|
|
4073
5412
|
writeFileSync,
|
|
4074
5413
|
rmSync,
|
|
4075
|
-
existsSync:
|
|
5414
|
+
existsSync: existsSync4,
|
|
4076
5415
|
mkdirSync
|
|
4077
5416
|
};
|
|
4078
5417
|
function buildTypeScriptArgs(context) {
|
|
@@ -4080,19 +5419,19 @@ function buildTypeScriptArgs(context) {
|
|
|
4080
5419
|
return scope === VALIDATION_SCOPES.FULL ? ["tsc", "--noEmit"] : ["tsc", "--project", configFile];
|
|
4081
5420
|
}
|
|
4082
5421
|
async function createFileSpecificTsconfig(scope, files, deps = defaultTypeScriptDeps) {
|
|
4083
|
-
const tempDir = await deps.mkdtemp(
|
|
4084
|
-
const configPath =
|
|
5422
|
+
const tempDir = await deps.mkdtemp(join16(tmpdir(), "validate-ts-"));
|
|
5423
|
+
const configPath = join16(tempDir, "tsconfig.json");
|
|
4085
5424
|
const baseConfigFile = TSCONFIG_FILES[scope];
|
|
4086
5425
|
const projectRoot = process.cwd();
|
|
4087
|
-
const absoluteFiles = files.map((file) =>
|
|
5426
|
+
const absoluteFiles = files.map((file) => isAbsolute3(file) ? file : join16(projectRoot, file));
|
|
4088
5427
|
const tempConfig = {
|
|
4089
|
-
extends:
|
|
5428
|
+
extends: join16(projectRoot, baseConfigFile),
|
|
4090
5429
|
files: absoluteFiles,
|
|
4091
5430
|
include: [],
|
|
4092
5431
|
exclude: [],
|
|
4093
5432
|
compilerOptions: {
|
|
4094
5433
|
noEmit: true,
|
|
4095
|
-
typeRoots: [
|
|
5434
|
+
typeRoots: [join16(projectRoot, "node_modules", "@types")],
|
|
4096
5435
|
types: ["node"]
|
|
4097
5436
|
}
|
|
4098
5437
|
};
|
|
@@ -4112,7 +5451,7 @@ async function validateTypeScript(scope, typescriptScope, files, runner = defaul
|
|
|
4112
5451
|
if (files && files.length > 0) {
|
|
4113
5452
|
const { configPath, cleanup } = await createFileSpecificTsconfig(scope, files, deps);
|
|
4114
5453
|
try {
|
|
4115
|
-
return await new Promise((
|
|
5454
|
+
return await new Promise((resolve6) => {
|
|
4116
5455
|
const tscProcess = runner.spawn("npx", ["tsc", "--project", configPath], {
|
|
4117
5456
|
cwd: process.cwd(),
|
|
4118
5457
|
stdio: "inherit"
|
|
@@ -4120,14 +5459,14 @@ async function validateTypeScript(scope, typescriptScope, files, runner = defaul
|
|
|
4120
5459
|
tscProcess.on("close", (code) => {
|
|
4121
5460
|
cleanup();
|
|
4122
5461
|
if (code === 0) {
|
|
4123
|
-
|
|
5462
|
+
resolve6({ success: true, skipped: false });
|
|
4124
5463
|
} else {
|
|
4125
|
-
|
|
5464
|
+
resolve6({ success: false, error: `TypeScript exited with code ${code}` });
|
|
4126
5465
|
}
|
|
4127
5466
|
});
|
|
4128
5467
|
tscProcess.on("error", (error) => {
|
|
4129
5468
|
cleanup();
|
|
4130
|
-
|
|
5469
|
+
resolve6({ success: false, error: error.message });
|
|
4131
5470
|
});
|
|
4132
5471
|
});
|
|
4133
5472
|
} catch (error) {
|
|
@@ -4139,56 +5478,37 @@ async function validateTypeScript(scope, typescriptScope, files, runner = defaul
|
|
|
4139
5478
|
tool = "npx";
|
|
4140
5479
|
tscArgs = buildTypeScriptArgs({ scope, configFile });
|
|
4141
5480
|
}
|
|
4142
|
-
return new Promise((
|
|
5481
|
+
return new Promise((resolve6) => {
|
|
4143
5482
|
const tscProcess = runner.spawn(tool, tscArgs, {
|
|
4144
5483
|
cwd: process.cwd(),
|
|
4145
5484
|
stdio: "inherit"
|
|
4146
5485
|
});
|
|
4147
5486
|
tscProcess.on("close", (code) => {
|
|
4148
5487
|
if (code === 0) {
|
|
4149
|
-
|
|
5488
|
+
resolve6({ success: true, skipped: false });
|
|
4150
5489
|
} else {
|
|
4151
|
-
|
|
5490
|
+
resolve6({ success: false, error: `TypeScript exited with code ${code}` });
|
|
4152
5491
|
}
|
|
4153
5492
|
});
|
|
4154
5493
|
tscProcess.on("error", (error) => {
|
|
4155
|
-
|
|
5494
|
+
resolve6({ success: false, error: error.message });
|
|
4156
5495
|
});
|
|
4157
5496
|
});
|
|
4158
5497
|
}
|
|
4159
|
-
var typescriptStep = {
|
|
4160
|
-
id: STEP_IDS.TYPESCRIPT,
|
|
4161
|
-
name: STEP_NAMES.TYPESCRIPT,
|
|
4162
|
-
description: STEP_DESCRIPTIONS.TYPESCRIPT,
|
|
4163
|
-
enabled: (context) => context.enabledValidations[VALIDATION_KEYS.TYPESCRIPT] === true && validationEnabled(VALIDATION_KEYS.TYPESCRIPT),
|
|
4164
|
-
execute: async (context) => {
|
|
4165
|
-
const startTime = performance.now();
|
|
4166
|
-
try {
|
|
4167
|
-
const result = await validateTypeScript(
|
|
4168
|
-
context.scope,
|
|
4169
|
-
context.scopeConfig,
|
|
4170
|
-
context.validatedFiles
|
|
4171
|
-
);
|
|
4172
|
-
return {
|
|
4173
|
-
success: result.success,
|
|
4174
|
-
error: result.error,
|
|
4175
|
-
duration: performance.now() - startTime,
|
|
4176
|
-
skipped: result.skipped
|
|
4177
|
-
};
|
|
4178
|
-
} catch (error) {
|
|
4179
|
-
return {
|
|
4180
|
-
success: false,
|
|
4181
|
-
error: error instanceof Error ? error.message : String(error),
|
|
4182
|
-
duration: performance.now() - startTime
|
|
4183
|
-
};
|
|
4184
|
-
}
|
|
4185
|
-
}
|
|
4186
|
-
};
|
|
4187
5498
|
|
|
4188
5499
|
// src/commands/validation/typescript.ts
|
|
5500
|
+
var TYPESCRIPT_ABSENT_MESSAGE4 = "\u23ED Skipping TypeScript (TypeScript not detected in project)";
|
|
4189
5501
|
async function typescriptCommand(options) {
|
|
4190
5502
|
const { cwd, scope = "full", files, quiet } = options;
|
|
4191
5503
|
const startTime = Date.now();
|
|
5504
|
+
const tsDetection = detectTypeScript(cwd);
|
|
5505
|
+
if (!tsDetection.present) {
|
|
5506
|
+
return {
|
|
5507
|
+
exitCode: 0,
|
|
5508
|
+
output: quiet ? "" : TYPESCRIPT_ABSENT_MESSAGE4,
|
|
5509
|
+
durationMs: Date.now() - startTime
|
|
5510
|
+
};
|
|
5511
|
+
}
|
|
4192
5512
|
const toolResult = await discoverTool("typescript", { projectRoot: cwd });
|
|
4193
5513
|
if (!toolResult.found) {
|
|
4194
5514
|
const skipMessage = formatSkipMessage("TypeScript", toolResult);
|
|
@@ -4207,7 +5527,7 @@ async function typescriptCommand(options) {
|
|
|
4207
5527
|
}
|
|
4208
5528
|
|
|
4209
5529
|
// src/commands/validation/all.ts
|
|
4210
|
-
var TOTAL_STEPS =
|
|
5530
|
+
var TOTAL_STEPS = 6;
|
|
4211
5531
|
function formatStepWithTiming(stepNumber, result, quiet) {
|
|
4212
5532
|
if (quiet || !result.output) return "";
|
|
4213
5533
|
const timing = result.durationMs === void 0 ? "" : ` (${formatDuration(result.durationMs)})`;
|
|
@@ -4233,6 +5553,14 @@ async function allCommand(options) {
|
|
|
4233
5553
|
const tsOutput = formatStepWithTiming(4, tsResult, quiet);
|
|
4234
5554
|
if (tsOutput) outputs.push(tsOutput);
|
|
4235
5555
|
if (tsResult.exitCode !== 0) hasFailure = true;
|
|
5556
|
+
const markdownResult = await markdownCommand({ cwd, files, quiet });
|
|
5557
|
+
const markdownOutput = formatStepWithTiming(5, markdownResult, quiet);
|
|
5558
|
+
if (markdownOutput) outputs.push(markdownOutput);
|
|
5559
|
+
if (markdownResult.exitCode !== 0) hasFailure = true;
|
|
5560
|
+
const literalResult = await literalCommand({ cwd, files, quiet, json });
|
|
5561
|
+
const literalOutput = formatStepWithTiming(6, literalResult, quiet);
|
|
5562
|
+
if (literalOutput) outputs.push(literalOutput);
|
|
5563
|
+
if (literalResult.exitCode !== 0) hasFailure = true;
|
|
4236
5564
|
const totalDurationMs = Date.now() - startTime;
|
|
4237
5565
|
if (!quiet) {
|
|
4238
5566
|
const summary = formatSummary({ success: !hasFailure, totalDurationMs });
|
|
@@ -4245,12 +5573,121 @@ async function allCommand(options) {
|
|
|
4245
5573
|
};
|
|
4246
5574
|
}
|
|
4247
5575
|
|
|
5576
|
+
// src/lib/sanitize-cli-argument.ts
|
|
5577
|
+
var MAX_CLI_ARGUMENT_DISPLAY_LENGTH = 120;
|
|
5578
|
+
var ELLIPSIS_TOKEN = "...";
|
|
5579
|
+
var SENTINEL_UNDEFINED = "<undefined>";
|
|
5580
|
+
var SENTINEL_NULL = "<null>";
|
|
5581
|
+
var SENTINEL_EMPTY = "<empty>";
|
|
5582
|
+
var CONTROL_CHAR_UPPER_BOUND = 31;
|
|
5583
|
+
var DEL_CHAR_CODE = 127;
|
|
5584
|
+
var HEX_RADIX = 16;
|
|
5585
|
+
var HEX_PAD = 2;
|
|
5586
|
+
function nonStringSentinel(type) {
|
|
5587
|
+
return `<non-string:${type}>`;
|
|
5588
|
+
}
|
|
5589
|
+
function sanitizeCliArgument(input) {
|
|
5590
|
+
if (input === void 0) return SENTINEL_UNDEFINED;
|
|
5591
|
+
if (input === null) return SENTINEL_NULL;
|
|
5592
|
+
if (typeof input !== "string") return nonStringSentinel(typeof input);
|
|
5593
|
+
if (input.length === 0) return SENTINEL_EMPTY;
|
|
5594
|
+
const escaped = escapeControlCharacters(input);
|
|
5595
|
+
return truncate(escaped);
|
|
5596
|
+
}
|
|
5597
|
+
function escapeControlCharacters(value) {
|
|
5598
|
+
let out = "";
|
|
5599
|
+
for (const char of value) {
|
|
5600
|
+
const code = char.codePointAt(0);
|
|
5601
|
+
if (code === void 0) continue;
|
|
5602
|
+
if (code <= CONTROL_CHAR_UPPER_BOUND || code === DEL_CHAR_CODE) {
|
|
5603
|
+
out += `\\x${code.toString(HEX_RADIX).padStart(HEX_PAD, "0")}`;
|
|
5604
|
+
} else {
|
|
5605
|
+
out += char;
|
|
5606
|
+
}
|
|
5607
|
+
}
|
|
5608
|
+
return out;
|
|
5609
|
+
}
|
|
5610
|
+
function truncate(value) {
|
|
5611
|
+
if (value.length <= MAX_CLI_ARGUMENT_DISPLAY_LENGTH) return value;
|
|
5612
|
+
return value.slice(0, MAX_CLI_ARGUMENT_DISPLAY_LENGTH - ELLIPSIS_TOKEN.length) + ELLIPSIS_TOKEN;
|
|
5613
|
+
}
|
|
5614
|
+
|
|
4248
5615
|
// src/domains/validation/index.ts
|
|
5616
|
+
var validationCliDefinition = {
|
|
5617
|
+
domain: {
|
|
5618
|
+
commandName: "validation",
|
|
5619
|
+
alias: "v",
|
|
5620
|
+
description: "Run code validation tools"
|
|
5621
|
+
},
|
|
5622
|
+
subcommands: {
|
|
5623
|
+
typescript: {
|
|
5624
|
+
commandName: "typescript",
|
|
5625
|
+
alias: "ts",
|
|
5626
|
+
description: "Run TypeScript type checking"
|
|
5627
|
+
},
|
|
5628
|
+
lint: {
|
|
5629
|
+
commandName: "lint",
|
|
5630
|
+
description: "Run ESLint"
|
|
5631
|
+
},
|
|
5632
|
+
circular: {
|
|
5633
|
+
commandName: "circular",
|
|
5634
|
+
description: "Check for circular dependencies"
|
|
5635
|
+
},
|
|
5636
|
+
knip: {
|
|
5637
|
+
commandName: "knip",
|
|
5638
|
+
description: "Detect unused code"
|
|
5639
|
+
},
|
|
5640
|
+
literal: {
|
|
5641
|
+
commandName: "literal",
|
|
5642
|
+
description: "Detect cross-file literal reuse between source and tests"
|
|
5643
|
+
},
|
|
5644
|
+
markdown: {
|
|
5645
|
+
commandName: "markdown",
|
|
5646
|
+
alias: "md",
|
|
5647
|
+
description: "Validate markdown link integrity and structure"
|
|
5648
|
+
},
|
|
5649
|
+
all: {
|
|
5650
|
+
commandName: "all",
|
|
5651
|
+
description: "Run all validations"
|
|
5652
|
+
}
|
|
5653
|
+
},
|
|
5654
|
+
commanderHelpOperands: {
|
|
5655
|
+
subcommand: "help",
|
|
5656
|
+
longFlag: "--help",
|
|
5657
|
+
shortFlag: "-h"
|
|
5658
|
+
},
|
|
5659
|
+
diagnostics: {
|
|
5660
|
+
unknownSubcommand: {
|
|
5661
|
+
messageLabel: "unknown subcommand",
|
|
5662
|
+
exitCode: 1
|
|
5663
|
+
}
|
|
5664
|
+
}
|
|
5665
|
+
};
|
|
5666
|
+
var validationSubcommandOperands = Object.values(validationCliDefinition.subcommands).flatMap(
|
|
5667
|
+
(subcommand) => {
|
|
5668
|
+
const operands = [subcommand.commandName];
|
|
5669
|
+
if (subcommand.alias !== void 0) operands.push(subcommand.alias);
|
|
5670
|
+
return operands;
|
|
5671
|
+
}
|
|
5672
|
+
);
|
|
5673
|
+
var validationKnownOperands = /* @__PURE__ */ new Set([
|
|
5674
|
+
...validationSubcommandOperands,
|
|
5675
|
+
...Object.values(validationCliDefinition.commanderHelpOperands)
|
|
5676
|
+
]);
|
|
5677
|
+
var validationOptionPrefix = validationCliDefinition.commanderHelpOperands.longFlag.slice(0, 1);
|
|
4249
5678
|
function addCommonOptions(cmd) {
|
|
4250
5679
|
return cmd.option("--scope <scope>", "Validation scope (full|production)", "full").option("--files <paths...>", "Specific files/directories to validate").option("--quiet", "Suppress progress output").option("--json", "Output results as JSON");
|
|
4251
5680
|
}
|
|
5681
|
+
function addValidationSubcommand(validationCmd, definition) {
|
|
5682
|
+
let subcommand = validationCmd.command(definition.commandName).description(definition.description);
|
|
5683
|
+
if (definition.alias !== void 0) {
|
|
5684
|
+
subcommand = subcommand.alias(definition.alias);
|
|
5685
|
+
}
|
|
5686
|
+
return subcommand;
|
|
5687
|
+
}
|
|
4252
5688
|
function registerValidationCommands(validationCmd) {
|
|
4253
|
-
const
|
|
5689
|
+
const { subcommands } = validationCliDefinition;
|
|
5690
|
+
const tsCmd = addValidationSubcommand(validationCmd, subcommands.typescript).action(async (options) => {
|
|
4254
5691
|
const result = await typescriptCommand({
|
|
4255
5692
|
cwd: process.cwd(),
|
|
4256
5693
|
scope: options.scope,
|
|
@@ -4262,7 +5699,7 @@ function registerValidationCommands(validationCmd) {
|
|
|
4262
5699
|
process.exit(result.exitCode);
|
|
4263
5700
|
});
|
|
4264
5701
|
addCommonOptions(tsCmd);
|
|
4265
|
-
const lintCmd = validationCmd.
|
|
5702
|
+
const lintCmd = addValidationSubcommand(validationCmd, subcommands.lint).option("--fix", "Auto-fix issues").action(async (options) => {
|
|
4266
5703
|
const result = await lintCommand({
|
|
4267
5704
|
cwd: process.cwd(),
|
|
4268
5705
|
scope: options.scope,
|
|
@@ -4275,7 +5712,7 @@ function registerValidationCommands(validationCmd) {
|
|
|
4275
5712
|
process.exit(result.exitCode);
|
|
4276
5713
|
});
|
|
4277
5714
|
addCommonOptions(lintCmd);
|
|
4278
|
-
const circularCmd = validationCmd.
|
|
5715
|
+
const circularCmd = addValidationSubcommand(validationCmd, subcommands.circular).action(async (options) => {
|
|
4279
5716
|
const result = await circularCommand({
|
|
4280
5717
|
cwd: process.cwd(),
|
|
4281
5718
|
quiet: options.quiet,
|
|
@@ -4285,7 +5722,7 @@ function registerValidationCommands(validationCmd) {
|
|
|
4285
5722
|
process.exit(result.exitCode);
|
|
4286
5723
|
});
|
|
4287
5724
|
addCommonOptions(circularCmd);
|
|
4288
|
-
const knipCmd = validationCmd.
|
|
5725
|
+
const knipCmd = addValidationSubcommand(validationCmd, subcommands.knip).action(async (options) => {
|
|
4289
5726
|
const result = await knipCommand({
|
|
4290
5727
|
cwd: process.cwd(),
|
|
4291
5728
|
quiet: options.quiet,
|
|
@@ -4295,7 +5732,42 @@ function registerValidationCommands(validationCmd) {
|
|
|
4295
5732
|
process.exit(result.exitCode);
|
|
4296
5733
|
});
|
|
4297
5734
|
addCommonOptions(knipCmd);
|
|
4298
|
-
const
|
|
5735
|
+
const literalCmd = addValidationSubcommand(validationCmd, subcommands.literal).option(
|
|
5736
|
+
"--allowlist-existing",
|
|
5737
|
+
"Append every current finding's value to literal.allowlist.include and exit"
|
|
5738
|
+
).addHelpText(
|
|
5739
|
+
"after",
|
|
5740
|
+
"\nEnabled for TypeScript projects by default. Set LITERAL_VALIDATION_ENABLED=0\nto skip (useful when migrating a project with many existing violations)."
|
|
5741
|
+
).action(async (options) => {
|
|
5742
|
+
if (options.allowlistExisting) {
|
|
5743
|
+
const result2 = await allowlistExisting({ projectRoot: process.cwd() });
|
|
5744
|
+
if (result2.output) console.log(result2.output);
|
|
5745
|
+
process.exit(result2.exitCode);
|
|
5746
|
+
}
|
|
5747
|
+
const result = await literalCommand({
|
|
5748
|
+
cwd: process.cwd(),
|
|
5749
|
+
files: options.files,
|
|
5750
|
+
quiet: options.quiet,
|
|
5751
|
+
json: options.json
|
|
5752
|
+
});
|
|
5753
|
+
if (result.output) console.log(result.output);
|
|
5754
|
+
process.exit(result.exitCode);
|
|
5755
|
+
});
|
|
5756
|
+
addCommonOptions(literalCmd);
|
|
5757
|
+
const markdownCmd = addValidationSubcommand(validationCmd, subcommands.markdown).addHelpText(
|
|
5758
|
+
"after",
|
|
5759
|
+
"\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."
|
|
5760
|
+
).action(async (options) => {
|
|
5761
|
+
const result = await markdownCommand({
|
|
5762
|
+
cwd: process.cwd(),
|
|
5763
|
+
files: options.files,
|
|
5764
|
+
quiet: options.quiet
|
|
5765
|
+
});
|
|
5766
|
+
if (result.output) console.log(result.output);
|
|
5767
|
+
process.exit(result.exitCode);
|
|
5768
|
+
});
|
|
5769
|
+
addCommonOptions(markdownCmd);
|
|
5770
|
+
const allCmd = addValidationSubcommand(validationCmd, subcommands.all).option("--fix", "Auto-fix ESLint issues").action(async (options) => {
|
|
4299
5771
|
const result = await allCommand({
|
|
4300
5772
|
cwd: process.cwd(),
|
|
4301
5773
|
scope: options.scope,
|
|
@@ -4309,11 +5781,24 @@ function registerValidationCommands(validationCmd) {
|
|
|
4309
5781
|
});
|
|
4310
5782
|
addCommonOptions(allCmd);
|
|
4311
5783
|
}
|
|
5784
|
+
function handleUnknownSubcommand(operands) {
|
|
5785
|
+
const [first] = operands;
|
|
5786
|
+
const sanitized = sanitizeCliArgument(first);
|
|
5787
|
+
const { domain, diagnostics } = validationCliDefinition;
|
|
5788
|
+
const { unknownSubcommand } = diagnostics;
|
|
5789
|
+
process.stderr.write(`spx ${domain.commandName}: ${unknownSubcommand.messageLabel}: ${sanitized}
|
|
5790
|
+
`);
|
|
5791
|
+
process.exit(unknownSubcommand.exitCode);
|
|
5792
|
+
}
|
|
4312
5793
|
var validationDomain = {
|
|
4313
|
-
name:
|
|
4314
|
-
description:
|
|
5794
|
+
name: validationCliDefinition.domain.commandName,
|
|
5795
|
+
description: validationCliDefinition.domain.description,
|
|
4315
5796
|
register: (program2) => {
|
|
4316
|
-
const
|
|
5797
|
+
const { domain } = validationCliDefinition;
|
|
5798
|
+
const validationCmd = program2.command(domain.commandName).alias(domain.alias).description(domain.description);
|
|
5799
|
+
validationCmd.on("command:*", (operands) => {
|
|
5800
|
+
handleUnknownSubcommand(operands);
|
|
5801
|
+
});
|
|
4317
5802
|
registerValidationCommands(validationCmd);
|
|
4318
5803
|
}
|
|
4319
5804
|
};
|
|
@@ -4323,7 +5808,9 @@ var require3 = createRequire2(import.meta.url);
|
|
|
4323
5808
|
var { version } = require3("../package.json");
|
|
4324
5809
|
var program = new Command();
|
|
4325
5810
|
program.name("spx").description("Fast, deterministic CLI tool for spec workflow management").version(version);
|
|
5811
|
+
auditDomain.register(program);
|
|
4326
5812
|
claudeDomain.register(program);
|
|
5813
|
+
configDomain.register(program);
|
|
4327
5814
|
sessionDomain.register(program);
|
|
4328
5815
|
specDomain.register(program);
|
|
4329
5816
|
validationDomain.register(program);
|