@lumenflow/cli 3.18.0 → 3.18.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 +44 -43
- package/dist/chunk-2D2VOCA4.js +37 -0
- package/dist/chunk-2D5KFYGX.js +284 -0
- package/dist/chunk-2GXVIN57.js +14072 -0
- package/dist/chunk-2MQ7HZWZ.js +26 -0
- package/dist/chunk-2UFQ3A3C.js +643 -0
- package/dist/chunk-3RG5ZIWI.js +10 -0
- package/dist/chunk-4N74J3UT.js +15 -0
- package/dist/chunk-5GTOXFYR.js +392 -0
- package/dist/chunk-5VY6MQMC.js +240 -0
- package/dist/chunk-67XVPMRY.js +1297 -0
- package/dist/chunk-6HO4GWJE.js +164 -0
- package/dist/chunk-6W5XHWYV.js +1890 -0
- package/dist/chunk-6X4EMYJQ.js +64 -0
- package/dist/chunk-6XYXI2NQ.js +772 -0
- package/dist/chunk-7ANSOV6Q.js +285 -0
- package/dist/chunk-A624LFLB.js +1380 -0
- package/dist/chunk-ADN5NHG4.js +126 -0
- package/dist/chunk-B7YJYJKG.js +33 -0
- package/dist/chunk-CCLHCPKG.js +210 -0
- package/dist/chunk-CK36VROC.js +1584 -0
- package/dist/chunk-D3UOFRSB.js +81 -0
- package/dist/chunk-DFR4DJBM.js +230 -0
- package/dist/chunk-DSYBDHYH.js +79 -0
- package/dist/chunk-DWMLTXKQ.js +1176 -0
- package/dist/chunk-E3REJTAJ.js +28 -0
- package/dist/chunk-EA3IVO64.js +633 -0
- package/dist/chunk-EK2AKZKD.js +55 -0
- package/dist/chunk-ELD7JTTT.js +343 -0
- package/dist/chunk-EX6TT2XI.js +195 -0
- package/dist/chunk-EXINSFZE.js +82 -0
- package/dist/chunk-EZ6ZBYBM.js +510 -0
- package/dist/chunk-FBKAPTJ2.js +16 -0
- package/dist/chunk-FVLV5RYH.js +1118 -0
- package/dist/chunk-GDNSBQVK.js +2485 -0
- package/dist/chunk-GPQHMBNN.js +278 -0
- package/dist/chunk-GTFJB67L.js +68 -0
- package/dist/chunk-HANJXVKW.js +1127 -0
- package/dist/chunk-HEVS5YLD.js +269 -0
- package/dist/chunk-HMEVZKPQ.js +9 -0
- package/dist/chunk-HRGSYNLM.js +3511 -0
- package/dist/chunk-ISZR5N4K.js +60 -0
- package/dist/chunk-J6SUPR2C.js +226 -0
- package/dist/chunk-JERYVEIZ.js +244 -0
- package/dist/chunk-JHHWGL2N.js +87 -0
- package/dist/chunk-JONWQUB5.js +775 -0
- package/dist/chunk-K2DIWWDM.js +1766 -0
- package/dist/chunk-KY4PGL5V.js +969 -0
- package/dist/chunk-L737LQ4C.js +1285 -0
- package/dist/chunk-LFTWYIB2.js +497 -0
- package/dist/chunk-LV47RFNJ.js +41 -0
- package/dist/chunk-MKSAITI7.js +15 -0
- package/dist/chunk-MZ7RKIX4.js +212 -0
- package/dist/chunk-NAP6CFSO.js +84 -0
- package/dist/chunk-ND6MY37M.js +16 -0
- package/dist/chunk-NMG736UR.js +683 -0
- package/dist/chunk-NRAXROED.js +32 -0
- package/dist/chunk-NRIZR3A7.js +690 -0
- package/dist/chunk-NX43BG3M.js +233 -0
- package/dist/chunk-O645XLSI.js +297 -0
- package/dist/chunk-OMJD6A3S.js +235 -0
- package/dist/chunk-QB6SJD4T.js +430 -0
- package/dist/chunk-QFSTL4J3.js +276 -0
- package/dist/chunk-QLGDFMFX.js +212 -0
- package/dist/chunk-RIAAGL2E.js +13 -0
- package/dist/chunk-RWO5XMZ6.js +86 -0
- package/dist/chunk-RXRKBBSM.js +149 -0
- package/dist/chunk-RZOZMML6.js +363 -0
- package/dist/chunk-U7I7FS7T.js +113 -0
- package/dist/chunk-UI42RODY.js +717 -0
- package/dist/chunk-UTVMVSCO.js +519 -0
- package/dist/chunk-V6OJGLBA.js +1746 -0
- package/dist/chunk-W2JHVH7D.js +152 -0
- package/dist/chunk-WD3Y7VQN.js +280 -0
- package/dist/chunk-WOCTQ5MS.js +303 -0
- package/dist/chunk-WZR3ZUNN.js +696 -0
- package/dist/chunk-XGI665H7.js +150 -0
- package/dist/chunk-XKY65P2T.js +304 -0
- package/dist/chunk-Y4CQZY65.js +57 -0
- package/dist/chunk-YFEXKLVE.js +194 -0
- package/dist/chunk-YHO3HS5X.js +287 -0
- package/dist/chunk-YLS7AZSX.js +738 -0
- package/dist/chunk-ZE473AO6.js +49 -0
- package/dist/chunk-ZF747T3O.js +644 -0
- package/dist/chunk-ZHCZHZH3.js +43 -0
- package/dist/chunk-ZZNZX2XY.js +87 -0
- package/dist/config-set.js +10 -1
- package/dist/config-set.js.map +1 -1
- package/dist/constants-7QAP3VQ4.js +23 -0
- package/dist/dist-IY3UUMWK.js +33 -0
- package/dist/gate-co-change.js +5 -2
- package/dist/gate-co-change.js.map +1 -1
- package/dist/initiative-edit.js +8 -3
- package/dist/initiative-edit.js.map +1 -1
- package/dist/invariants-runner-W5RGHCSU.js +27 -0
- package/dist/lane-lock-6J36HD5O.js +35 -0
- package/dist/lumenflow-upgrade.js +49 -0
- package/dist/lumenflow-upgrade.js.map +1 -1
- package/dist/mem-checkpoint-core-EANG2GVN.js +14 -0
- package/dist/mem-signal-core-2LZ2WYHW.js +19 -0
- package/dist/memory-store-OLB5FO7K.js +18 -0
- package/dist/service-6BYCOCO5.js +13 -0
- package/dist/spawn-policy-resolver-NTSZYQ6R.js +17 -0
- package/dist/spawn-task-builder-R4E2BHSW.js +22 -0
- package/dist/wu-done-pr-WLFFFEPJ.js +25 -0
- package/dist/wu-done-validation-3J5E36FE.js +30 -0
- package/dist/wu-duplicate-id-detector-5S7JHELK.js +232 -0
- package/package.json +8 -8
- package/packs/agent-runtime/.turbo/turbo-build.log +1 -1
- package/packs/agent-runtime/package.json +1 -1
- package/packs/sidekick/.turbo/turbo-build.log +1 -1
- package/packs/sidekick/.turbo/turbo-test.log +12 -0
- package/packs/sidekick/.turbo/turbo-typecheck.log +4 -0
- package/packs/sidekick/package.json +1 -1
- package/packs/software-delivery/.turbo/turbo-build.log +1 -1
- package/packs/software-delivery/.turbo/turbo-typecheck.log +4 -0
- package/packs/software-delivery/package.json +1 -1
|
@@ -0,0 +1,1285 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createGitForPath,
|
|
3
|
+
getGitForCwd
|
|
4
|
+
} from "./chunk-2UFQ3A3C.js";
|
|
5
|
+
import {
|
|
6
|
+
validateAutomatedTestRequirement
|
|
7
|
+
} from "./chunk-U7I7FS7T.js";
|
|
8
|
+
import {
|
|
9
|
+
PLACEHOLDER_SENTINEL,
|
|
10
|
+
normalizeToDateString,
|
|
11
|
+
parseYAML
|
|
12
|
+
} from "./chunk-NRIZR3A7.js";
|
|
13
|
+
import {
|
|
14
|
+
BRANCHES,
|
|
15
|
+
EMOJI,
|
|
16
|
+
FILE_SYSTEM,
|
|
17
|
+
GIT_COMMANDS,
|
|
18
|
+
GIT_REFS,
|
|
19
|
+
LOG_PREFIX,
|
|
20
|
+
STRING_LITERALS,
|
|
21
|
+
VALIDATION
|
|
22
|
+
} from "./chunk-DWMLTXKQ.js";
|
|
23
|
+
import {
|
|
24
|
+
TEST_TYPES,
|
|
25
|
+
WU_EXPOSURE,
|
|
26
|
+
WU_STATUS,
|
|
27
|
+
WU_TYPES,
|
|
28
|
+
getConfig
|
|
29
|
+
} from "./chunk-V6OJGLBA.js";
|
|
30
|
+
import {
|
|
31
|
+
ErrorCodes,
|
|
32
|
+
createError,
|
|
33
|
+
die
|
|
34
|
+
} from "./chunk-RXRKBBSM.js";
|
|
35
|
+
|
|
36
|
+
// ../core/dist/wu-done-validation.js
|
|
37
|
+
import path3 from "path";
|
|
38
|
+
import { existsSync as existsSync2, readFileSync } from "fs";
|
|
39
|
+
import { minimatch as minimatch2 } from "minimatch";
|
|
40
|
+
|
|
41
|
+
// ../core/dist/wu-rules-core.js
|
|
42
|
+
import path from "path";
|
|
43
|
+
import { minimatch } from "minimatch";
|
|
44
|
+
var RULE_CODES = {
|
|
45
|
+
CODE_PATH_SHAPE: "R001_CODE_PATH_SHAPE",
|
|
46
|
+
MINIMUM_TEST_INTENT: "R002_MINIMUM_TEST_INTENT",
|
|
47
|
+
CODE_PATH_EXISTENCE: "R003_CODE_PATH_EXISTENCE",
|
|
48
|
+
CODE_PATH_COVERAGE: "R004_CODE_PATH_COVERAGE",
|
|
49
|
+
PARITY_MISSING_SURFACE: "R005_PARITY_MISSING_SURFACE",
|
|
50
|
+
PARITY_UNAVAILABLE: "R005_PARITY_UNAVAILABLE",
|
|
51
|
+
TEST_CLASSIFICATION: "R007_TEST_CLASSIFICATION",
|
|
52
|
+
TEST_EXISTENCE: "R008_TEST_EXISTENCE"
|
|
53
|
+
};
|
|
54
|
+
var CLI_PACKAGE_JSON_PATH = "packages/@lumenflow/cli/package.json";
|
|
55
|
+
var REGISTRATION_SURFACES = {
|
|
56
|
+
PUBLIC_MANIFEST: "packages/@lumenflow/cli/src/public-manifest.ts",
|
|
57
|
+
MCP_TOOLS: "packages/@lumenflow/mcp/src/tools.ts"
|
|
58
|
+
};
|
|
59
|
+
var DEFAULT_HEAD_REF = "HEAD";
|
|
60
|
+
var AUTOMATED_TEST_BUCKETS = ["unit", "e2e", "integration"];
|
|
61
|
+
var BASIC_GLOB_CHAR_PATTERN = /[*?[\]{}]/;
|
|
62
|
+
var EXTGLOB_PATTERN = /[@!+*?]\(/;
|
|
63
|
+
function normalizeStringArray(value) {
|
|
64
|
+
if (!Array.isArray(value)) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
return value.filter((entry) => typeof entry === "string").map((entry) => entry.trim()).filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
function normalizeCodePathsRaw(value) {
|
|
70
|
+
if (!Array.isArray(value)) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
function normalizeTests(testsValue) {
|
|
76
|
+
const testsRecord = testsValue && typeof testsValue === "object" && !Array.isArray(testsValue) ? testsValue : {};
|
|
77
|
+
return {
|
|
78
|
+
manual: normalizeStringArray(testsRecord[TEST_TYPES.MANUAL]),
|
|
79
|
+
unit: normalizeStringArray(testsRecord[TEST_TYPES.UNIT]),
|
|
80
|
+
e2e: normalizeStringArray(testsRecord[TEST_TYPES.E2E]),
|
|
81
|
+
integration: normalizeStringArray(testsRecord[TEST_TYPES.INTEGRATION])
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function normalizeContext(input) {
|
|
85
|
+
const testsSource = input.tests && typeof input.tests === "object" ? input.tests : input.test_paths && typeof input.test_paths === "object" ? input.test_paths : {};
|
|
86
|
+
const codePathsRaw = normalizeCodePathsRaw(input.code_paths);
|
|
87
|
+
const codePaths = normalizeStringArray(input.code_paths);
|
|
88
|
+
return {
|
|
89
|
+
id: typeof input.id === "string" && input.id.trim() ? input.id.trim() : "(unknown-wu)",
|
|
90
|
+
type: typeof input.type === "string" ? input.type : void 0,
|
|
91
|
+
status: typeof input.status === "string" ? input.status : void 0,
|
|
92
|
+
codePathsRaw,
|
|
93
|
+
codePaths,
|
|
94
|
+
tests: normalizeTests(testsSource),
|
|
95
|
+
cwd: input.cwd?.trim() || process.cwd(),
|
|
96
|
+
baseRef: input.baseRef,
|
|
97
|
+
headRef: input.headRef || DEFAULT_HEAD_REF
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function isDocsOrProcess(type) {
|
|
101
|
+
return type === WU_TYPES.DOCUMENTATION || type === WU_TYPES.PROCESS;
|
|
102
|
+
}
|
|
103
|
+
function hasGlobPattern(pathValue) {
|
|
104
|
+
return BASIC_GLOB_CHAR_PATTERN.test(pathValue) || EXTGLOB_PATTERN.test(pathValue);
|
|
105
|
+
}
|
|
106
|
+
function normalizePathForCoverage(pathValue) {
|
|
107
|
+
return pathValue.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/{2,}/g, "/").replace(/\/$/, "");
|
|
108
|
+
}
|
|
109
|
+
function isDirectoryLikeCodePath(codePath) {
|
|
110
|
+
if (codePath.endsWith("/")) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
const fileName = path.posix.basename(codePath);
|
|
114
|
+
return !fileName.includes(".");
|
|
115
|
+
}
|
|
116
|
+
function addIssue(issues, issue) {
|
|
117
|
+
issues.push(issue);
|
|
118
|
+
}
|
|
119
|
+
function isCodePathCoveredByChangedFiles(options) {
|
|
120
|
+
const normalizedCodePath = normalizePathForCoverage(options.codePath);
|
|
121
|
+
if (!normalizedCodePath) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
const glob = hasGlobPattern(normalizedCodePath);
|
|
125
|
+
const directoryLike = isDirectoryLikeCodePath(options.codePath);
|
|
126
|
+
return options.changedFiles.some((changedFile) => {
|
|
127
|
+
const normalizedChangedFile = normalizePathForCoverage(changedFile);
|
|
128
|
+
if (!normalizedChangedFile) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
if (normalizedChangedFile === normalizedCodePath) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
if (glob) {
|
|
135
|
+
return minimatch(normalizedChangedFile, normalizedCodePath, { dot: true });
|
|
136
|
+
}
|
|
137
|
+
if (directoryLike) {
|
|
138
|
+
return normalizedChangedFile.startsWith(`${normalizedCodePath}/`);
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
function findMissingCodePathCoverage(options) {
|
|
144
|
+
const { codePaths, changedFiles } = options;
|
|
145
|
+
return codePaths.filter((codePath) => !isCodePathCoveredByChangedFiles({ codePath, changedFiles }));
|
|
146
|
+
}
|
|
147
|
+
function isPathLikeTestEntry(value) {
|
|
148
|
+
const trimmed = value.trim();
|
|
149
|
+
if (!trimmed) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
const hasWhitespace = /\s/.test(trimmed);
|
|
153
|
+
const hasGlob = hasGlobPattern(trimmed);
|
|
154
|
+
const hasFileSuffix = /(\.(test|spec)\.[A-Za-z0-9]+|\.[A-Za-z0-9]+)$/.test(trimmed);
|
|
155
|
+
if (hasWhitespace && !trimmed.startsWith("./") && !trimmed.startsWith("../") && !trimmed.startsWith("/") && !hasGlob && !hasFileSuffix) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("/")) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
if (hasGlobPattern(trimmed)) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
if (/\.(test|spec)\.[A-Za-z0-9]+$/i.test(trimmed)) {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
if (!trimmed.includes(" ") && /\.[A-Za-z0-9]+$/.test(trimmed)) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
function validateRule001PathShape(context, issues) {
|
|
176
|
+
context.codePathsRaw.forEach((entry, index) => {
|
|
177
|
+
if (typeof entry !== "string" || !entry.trim()) {
|
|
178
|
+
addIssue(issues, {
|
|
179
|
+
code: RULE_CODES.CODE_PATH_SHAPE,
|
|
180
|
+
severity: "error",
|
|
181
|
+
message: `code_paths[${index}] must be a non-empty string path or glob.`,
|
|
182
|
+
suggestion: "Provide a non-empty path/glob string or remove this entry."
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function validateRule002MinimumTestIntent(context, issues) {
|
|
188
|
+
if (isDocsOrProcess(context.type)) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (context.codePaths.length === 0) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const hasTestIntent = context.tests.manual.length > 0 || context.tests.unit.length > 0 || context.tests.e2e.length > 0 || context.tests.integration.length > 0;
|
|
195
|
+
if (!hasTestIntent) {
|
|
196
|
+
addIssue(issues, {
|
|
197
|
+
code: RULE_CODES.MINIMUM_TEST_INTENT,
|
|
198
|
+
severity: "error",
|
|
199
|
+
message: "At least one test entry is required across tests.manual, tests.unit, tests.e2e, or tests.integration.",
|
|
200
|
+
suggestion: "Add at least one test entry. Use tests.manual for descriptive checks when no automated path applies."
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function validateRule003CodePathExistence(context, issues, missingCodePaths, pathReferenceExists2) {
|
|
205
|
+
const missing = [];
|
|
206
|
+
for (const codePath of context.codePaths) {
|
|
207
|
+
const exists = await pathReferenceExists2(codePath, context.cwd);
|
|
208
|
+
if (!exists) {
|
|
209
|
+
missing.push(codePath);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (missing.length > 0) {
|
|
213
|
+
missingCodePaths.push(...missing);
|
|
214
|
+
addIssue(issues, {
|
|
215
|
+
code: RULE_CODES.CODE_PATH_EXISTENCE,
|
|
216
|
+
severity: "error",
|
|
217
|
+
message: `code_paths existence check failed for ${missing.length} path(s).`,
|
|
218
|
+
suggestion: "Create the missing files/glob targets, or update code_paths to match actual repository paths.",
|
|
219
|
+
metadata: { missingCodePaths: missing }
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async function validateRule004Coverage(context, issues, changedFilesOutput, missingCoverageCodePaths, resolveChangedFiles2) {
|
|
224
|
+
if (context.codePaths.length === 0) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const changedFiles = await resolveChangedFiles2({
|
|
228
|
+
cwd: context.cwd,
|
|
229
|
+
baseRef: context.baseRef,
|
|
230
|
+
headRef: context.headRef
|
|
231
|
+
});
|
|
232
|
+
if (!changedFiles.ok) {
|
|
233
|
+
const coverageReason = "reason" in changedFiles ? changedFiles.reason : "git diff unavailable";
|
|
234
|
+
missingCoverageCodePaths.push(...context.codePaths);
|
|
235
|
+
addIssue(issues, {
|
|
236
|
+
code: RULE_CODES.CODE_PATH_COVERAGE,
|
|
237
|
+
severity: "error",
|
|
238
|
+
message: `Unable to evaluate code_paths coverage: ${coverageReason}`,
|
|
239
|
+
suggestion: "Ensure git diff base is available (origin/main or main) and rerun from the claimed worktree/branch context."
|
|
240
|
+
});
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
changedFilesOutput.push(...changedFiles.files);
|
|
244
|
+
const missing = findMissingCodePathCoverage({
|
|
245
|
+
codePaths: context.codePaths,
|
|
246
|
+
changedFiles: changedFiles.files
|
|
247
|
+
});
|
|
248
|
+
if (missing.length > 0) {
|
|
249
|
+
missingCoverageCodePaths.push(...missing);
|
|
250
|
+
addIssue(issues, {
|
|
251
|
+
code: RULE_CODES.CODE_PATH_COVERAGE,
|
|
252
|
+
severity: "error",
|
|
253
|
+
message: `code_paths coverage failed: ${missing.length} scoped path(s) have no matching branch diff changes.`,
|
|
254
|
+
suggestion: "Commit changes that touch each missing code_path, or update code_paths to match actual branch scope.",
|
|
255
|
+
metadata: {
|
|
256
|
+
missingCodePaths: missing,
|
|
257
|
+
changedFiles: changedFiles.files,
|
|
258
|
+
baseRef: changedFiles.baseRef,
|
|
259
|
+
headRef: changedFiles.headRef
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function validateRule007AutomatedTestClassification(context, issues) {
|
|
265
|
+
const pathLikeEntries = {
|
|
266
|
+
unit: [],
|
|
267
|
+
e2e: [],
|
|
268
|
+
integration: []
|
|
269
|
+
};
|
|
270
|
+
for (const bucket of AUTOMATED_TEST_BUCKETS) {
|
|
271
|
+
for (const entry of context.tests[bucket]) {
|
|
272
|
+
if (!isPathLikeTestEntry(entry)) {
|
|
273
|
+
addIssue(issues, {
|
|
274
|
+
code: RULE_CODES.TEST_CLASSIFICATION,
|
|
275
|
+
severity: "error",
|
|
276
|
+
message: `tests.${bucket} entry is not path-like: "${entry}".`,
|
|
277
|
+
suggestion: `Move descriptive text to tests.manual and keep tests.${bucket} for file paths/globs only.`,
|
|
278
|
+
metadata: { bucket, value: entry }
|
|
279
|
+
});
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
pathLikeEntries[bucket].push(entry);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return pathLikeEntries;
|
|
286
|
+
}
|
|
287
|
+
async function validateRule008AutomatedTestExistence(context, issues, pathLikeEntries, missingTestPaths, pathReferenceExists2) {
|
|
288
|
+
const missing = [];
|
|
289
|
+
for (const bucket of AUTOMATED_TEST_BUCKETS) {
|
|
290
|
+
for (const testPath of pathLikeEntries[bucket]) {
|
|
291
|
+
const exists = await pathReferenceExists2(testPath, context.cwd);
|
|
292
|
+
if (!exists) {
|
|
293
|
+
missing.push(testPath);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (missing.length > 0) {
|
|
298
|
+
missingTestPaths.push(...missing);
|
|
299
|
+
addIssue(issues, {
|
|
300
|
+
code: RULE_CODES.TEST_EXISTENCE,
|
|
301
|
+
severity: "error",
|
|
302
|
+
message: `Automated test path existence failed for ${missing.length} path(s).`,
|
|
303
|
+
suggestion: "Create the missing automated test files/glob targets, or move non-path notes to tests.manual.",
|
|
304
|
+
metadata: { missingTestPaths: missing }
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async function validateRule005Parity(context, issues, resolveCliBinDiff2) {
|
|
309
|
+
const includesCliPackage = context.codePaths.includes(CLI_PACKAGE_JSON_PATH);
|
|
310
|
+
if (!includesCliPackage) {
|
|
311
|
+
return { state: "unchanged", headRef: context.headRef };
|
|
312
|
+
}
|
|
313
|
+
const diff = await resolveCliBinDiff2({
|
|
314
|
+
cwd: context.cwd,
|
|
315
|
+
baseRef: context.baseRef,
|
|
316
|
+
headRef: context.headRef
|
|
317
|
+
});
|
|
318
|
+
if (diff.state === "unavailable") {
|
|
319
|
+
addIssue(issues, {
|
|
320
|
+
code: RULE_CODES.PARITY_UNAVAILABLE,
|
|
321
|
+
severity: "warning",
|
|
322
|
+
message: `Skipped CLI registration parity check: ${diff.reason || "bin diff unavailable."}`,
|
|
323
|
+
suggestion: "Ensure git base/head refs are available, then rerun reality validation to enforce parity.",
|
|
324
|
+
metadata: {
|
|
325
|
+
baseRef: diff.baseRef,
|
|
326
|
+
headRef: diff.headRef
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
return diff;
|
|
330
|
+
}
|
|
331
|
+
if (diff.state === "unchanged") {
|
|
332
|
+
return diff;
|
|
333
|
+
}
|
|
334
|
+
const hasPublicManifest = context.codePaths.includes(REGISTRATION_SURFACES.PUBLIC_MANIFEST);
|
|
335
|
+
const hasMcpTools = context.codePaths.includes(REGISTRATION_SURFACES.MCP_TOOLS);
|
|
336
|
+
if (!hasPublicManifest) {
|
|
337
|
+
addIssue(issues, {
|
|
338
|
+
code: RULE_CODES.PARITY_MISSING_SURFACE,
|
|
339
|
+
severity: "error",
|
|
340
|
+
message: `CLI bin changed but '${REGISTRATION_SURFACES.PUBLIC_MANIFEST}' is missing from code_paths.`,
|
|
341
|
+
suggestion: `Add '${REGISTRATION_SURFACES.PUBLIC_MANIFEST}' to code_paths for CLI registration parity.`,
|
|
342
|
+
metadata: { surface: REGISTRATION_SURFACES.PUBLIC_MANIFEST, baseRef: diff.baseRef }
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (!hasMcpTools) {
|
|
346
|
+
addIssue(issues, {
|
|
347
|
+
code: RULE_CODES.PARITY_MISSING_SURFACE,
|
|
348
|
+
severity: "error",
|
|
349
|
+
message: `CLI bin changed but '${REGISTRATION_SURFACES.MCP_TOOLS}' is missing from code_paths.`,
|
|
350
|
+
suggestion: `Add '${REGISTRATION_SURFACES.MCP_TOOLS}' to code_paths for CLI registration parity.`,
|
|
351
|
+
metadata: { surface: REGISTRATION_SURFACES.MCP_TOOLS, baseRef: diff.baseRef }
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
return diff;
|
|
355
|
+
}
|
|
356
|
+
function finalizeValidation(issues, metadata) {
|
|
357
|
+
const errors = issues.filter((issue) => issue.severity === "error");
|
|
358
|
+
const warnings = issues.filter((issue) => issue.severity === "warning");
|
|
359
|
+
return {
|
|
360
|
+
valid: errors.length === 0,
|
|
361
|
+
issues,
|
|
362
|
+
errors,
|
|
363
|
+
warnings,
|
|
364
|
+
metadata
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function runCommonPhaseRules(context, issues) {
|
|
368
|
+
validateRule001PathShape(context, issues);
|
|
369
|
+
validateRule002MinimumTestIntent(context, issues);
|
|
370
|
+
}
|
|
371
|
+
function validateWURulesSync(input, options = {}) {
|
|
372
|
+
const phase = options.phase || "structural";
|
|
373
|
+
if (phase === "reality") {
|
|
374
|
+
throw createError(ErrorCodes.INVALID_ARGUMENT, 'validateWURulesSync does not support phase "reality". Use validateWURulesWithResolvers.');
|
|
375
|
+
}
|
|
376
|
+
const context = normalizeContext(input);
|
|
377
|
+
const issues = [];
|
|
378
|
+
runCommonPhaseRules(context, issues);
|
|
379
|
+
return finalizeValidation(issues, {
|
|
380
|
+
missingCodePaths: [],
|
|
381
|
+
missingCoverageCodePaths: [],
|
|
382
|
+
missingTestPaths: [],
|
|
383
|
+
changedFiles: [],
|
|
384
|
+
parityState: "unavailable"
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
async function validateWURulesWithResolvers(input, options = {}, resolvers) {
|
|
388
|
+
const phase = options.phase || "structural";
|
|
389
|
+
if (phase !== "reality") {
|
|
390
|
+
return validateWURulesSync(input, { phase });
|
|
391
|
+
}
|
|
392
|
+
const context = normalizeContext(input);
|
|
393
|
+
const issues = [];
|
|
394
|
+
const missingCodePaths = [];
|
|
395
|
+
const missingCoverageCodePaths = [];
|
|
396
|
+
const missingTestPaths = [];
|
|
397
|
+
const changedFiles = [];
|
|
398
|
+
runCommonPhaseRules(context, issues);
|
|
399
|
+
await validateRule003CodePathExistence(context, issues, missingCodePaths, resolvers.pathReferenceExists);
|
|
400
|
+
await validateRule004Coverage(context, issues, changedFiles, missingCoverageCodePaths, resolvers.resolveChangedFiles);
|
|
401
|
+
const parity = await validateRule005Parity(context, issues, resolvers.resolveCliBinDiff);
|
|
402
|
+
const pathLikeEntries = validateRule007AutomatedTestClassification(context, issues);
|
|
403
|
+
await validateRule008AutomatedTestExistence(context, issues, pathLikeEntries, missingTestPaths, resolvers.pathReferenceExists);
|
|
404
|
+
return finalizeValidation(issues, {
|
|
405
|
+
missingCodePaths,
|
|
406
|
+
missingCoverageCodePaths,
|
|
407
|
+
missingTestPaths,
|
|
408
|
+
changedFiles,
|
|
409
|
+
parityState: parity.state,
|
|
410
|
+
parityReason: parity.reason
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ../core/dist/wu-rules-resolvers.js
|
|
415
|
+
import { existsSync } from "fs";
|
|
416
|
+
import path2 from "path";
|
|
417
|
+
import { isDeepStrictEqual } from "util";
|
|
418
|
+
import fg from "fast-glob";
|
|
419
|
+
var DEFAULT_HEAD_REF2 = "HEAD";
|
|
420
|
+
var BASE_REF_CANDIDATES = [GIT_REFS.ORIGIN_MAIN, BRANCHES.MAIN];
|
|
421
|
+
var BASE_REF_UNAVAILABLE_REASON = `Unable to resolve git base ref (tried ${BASE_REF_CANDIDATES.join(", ")}).`;
|
|
422
|
+
var GLOB_IGNORE_PATTERNS = ["**/node_modules/**"];
|
|
423
|
+
var GLOB_INCLUDE_DOT_ENTRIES = true;
|
|
424
|
+
var GLOB_ONLY_FILES = false;
|
|
425
|
+
var GLOB_FOLLOW_SYMLINKS = false;
|
|
426
|
+
var GLOB_SUPPRESS_ERRORS = true;
|
|
427
|
+
var GLOB_REQUIRE_UNIQUE_MATCHES = true;
|
|
428
|
+
var PATH_NOT_FOUND_PATTERNS = [
|
|
429
|
+
/does not exist in/i,
|
|
430
|
+
/exists on disk, but not in/i,
|
|
431
|
+
/path .* not in/i,
|
|
432
|
+
/unknown revision or path/i,
|
|
433
|
+
/fatal: path /i
|
|
434
|
+
];
|
|
435
|
+
function getGlobOptions(cwd) {
|
|
436
|
+
return {
|
|
437
|
+
cwd,
|
|
438
|
+
dot: GLOB_INCLUDE_DOT_ENTRIES,
|
|
439
|
+
onlyFiles: GLOB_ONLY_FILES,
|
|
440
|
+
followSymbolicLinks: GLOB_FOLLOW_SYMLINKS,
|
|
441
|
+
suppressErrors: GLOB_SUPPRESS_ERRORS,
|
|
442
|
+
ignore: GLOB_IGNORE_PATTERNS,
|
|
443
|
+
unique: GLOB_REQUIRE_UNIQUE_MATCHES
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
function toErrorMessage(error) {
|
|
447
|
+
if (error instanceof Error) {
|
|
448
|
+
return error.message;
|
|
449
|
+
}
|
|
450
|
+
return String(error);
|
|
451
|
+
}
|
|
452
|
+
function splitLines(output) {
|
|
453
|
+
return output.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
454
|
+
}
|
|
455
|
+
function matchesAnyNotFoundPattern(message) {
|
|
456
|
+
return PATH_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(message));
|
|
457
|
+
}
|
|
458
|
+
async function readJsonFileAtRef(options) {
|
|
459
|
+
const git = createGitForPath(options.cwd);
|
|
460
|
+
try {
|
|
461
|
+
const output = await git.raw(["show", `${options.ref}:${options.filePath}`]);
|
|
462
|
+
const parsed = JSON.parse(output);
|
|
463
|
+
return { ok: true, value: parsed };
|
|
464
|
+
} catch (error) {
|
|
465
|
+
const message = toErrorMessage(error);
|
|
466
|
+
if (matchesAnyNotFoundPattern(message)) {
|
|
467
|
+
return { ok: false, missing: true };
|
|
468
|
+
}
|
|
469
|
+
return { ok: false, missing: false, reason: message };
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function isMissingRefResult(result) {
|
|
473
|
+
return !result.ok && "missing" in result && result.missing === true;
|
|
474
|
+
}
|
|
475
|
+
function isErrorRefResult(result) {
|
|
476
|
+
return !result.ok && "missing" in result && result.missing === false;
|
|
477
|
+
}
|
|
478
|
+
function pathReferenceExistsSync(reference, cwd) {
|
|
479
|
+
const normalizedReference = reference.trim();
|
|
480
|
+
if (!normalizedReference) {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
if (hasGlobPattern(normalizedReference)) {
|
|
484
|
+
try {
|
|
485
|
+
const matches = fg.sync(normalizedReference, getGlobOptions(cwd));
|
|
486
|
+
return matches.length > 0;
|
|
487
|
+
} catch {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const fullPath = path2.join(cwd, normalizedReference);
|
|
492
|
+
return existsSync(fullPath);
|
|
493
|
+
}
|
|
494
|
+
async function pathReferenceExists(reference, cwd) {
|
|
495
|
+
const normalizedReference = reference.trim();
|
|
496
|
+
if (!normalizedReference) {
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
if (hasGlobPattern(normalizedReference)) {
|
|
500
|
+
try {
|
|
501
|
+
const matches = await fg(normalizedReference, getGlobOptions(cwd));
|
|
502
|
+
return matches.length > 0;
|
|
503
|
+
} catch {
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const fullPath = path2.join(cwd, normalizedReference);
|
|
508
|
+
return existsSync(fullPath);
|
|
509
|
+
}
|
|
510
|
+
async function resolveBaseRef(options) {
|
|
511
|
+
if (options.baseRef && options.baseRef.trim()) {
|
|
512
|
+
return options.baseRef.trim();
|
|
513
|
+
}
|
|
514
|
+
const git = createGitForPath(options.cwd);
|
|
515
|
+
for (const candidateRef of BASE_REF_CANDIDATES) {
|
|
516
|
+
try {
|
|
517
|
+
if (await git.branchExists(candidateRef)) {
|
|
518
|
+
return candidateRef;
|
|
519
|
+
}
|
|
520
|
+
} catch {
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
async function resolveChangedFiles(options) {
|
|
526
|
+
const cwd = options.cwd;
|
|
527
|
+
const headRef = options.headRef || DEFAULT_HEAD_REF2;
|
|
528
|
+
const baseRef = await resolveBaseRef({ cwd, baseRef: options.baseRef });
|
|
529
|
+
if (!baseRef) {
|
|
530
|
+
return {
|
|
531
|
+
ok: false,
|
|
532
|
+
reason: BASE_REF_UNAVAILABLE_REASON
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
const git = createGitForPath(cwd);
|
|
536
|
+
try {
|
|
537
|
+
const output = await git.raw(["diff", "--name-only", `${baseRef}...${headRef}`]);
|
|
538
|
+
const files = splitLines(output).map((filePath) => normalizePathForCoverage(filePath));
|
|
539
|
+
return {
|
|
540
|
+
ok: true,
|
|
541
|
+
files,
|
|
542
|
+
baseRef,
|
|
543
|
+
headRef
|
|
544
|
+
};
|
|
545
|
+
} catch (error) {
|
|
546
|
+
return {
|
|
547
|
+
ok: false,
|
|
548
|
+
reason: toErrorMessage(error)
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
async function resolveCliBinDiff(options) {
|
|
553
|
+
const cwd = options.cwd;
|
|
554
|
+
const headRef = options.headRef || DEFAULT_HEAD_REF2;
|
|
555
|
+
const baseRef = await resolveBaseRef({ cwd, baseRef: options.baseRef });
|
|
556
|
+
if (!baseRef) {
|
|
557
|
+
return {
|
|
558
|
+
state: "unavailable",
|
|
559
|
+
reason: BASE_REF_UNAVAILABLE_REASON,
|
|
560
|
+
headRef
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
const baseDoc = await readJsonFileAtRef({ cwd, ref: baseRef, filePath: CLI_PACKAGE_JSON_PATH });
|
|
564
|
+
const headDoc = await readJsonFileAtRef({ cwd, ref: headRef, filePath: CLI_PACKAGE_JSON_PATH });
|
|
565
|
+
if (isErrorRefResult(headDoc)) {
|
|
566
|
+
return {
|
|
567
|
+
state: "unavailable",
|
|
568
|
+
reason: headDoc.reason,
|
|
569
|
+
baseRef,
|
|
570
|
+
headRef
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
if (isErrorRefResult(baseDoc)) {
|
|
574
|
+
return {
|
|
575
|
+
state: "unavailable",
|
|
576
|
+
reason: baseDoc.reason,
|
|
577
|
+
baseRef,
|
|
578
|
+
headRef
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
if (isMissingRefResult(baseDoc) && headDoc.ok) {
|
|
582
|
+
return {
|
|
583
|
+
state: "changed",
|
|
584
|
+
reason: `${CLI_PACKAGE_JSON_PATH} does not exist at ${baseRef} but exists at ${headRef}.`,
|
|
585
|
+
baseRef,
|
|
586
|
+
headRef
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
if (baseDoc.ok && isMissingRefResult(headDoc)) {
|
|
590
|
+
return {
|
|
591
|
+
state: "changed",
|
|
592
|
+
reason: `${CLI_PACKAGE_JSON_PATH} exists at ${baseRef} but not at ${headRef}.`,
|
|
593
|
+
baseRef,
|
|
594
|
+
headRef
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
if (isMissingRefResult(baseDoc) && isMissingRefResult(headDoc)) {
|
|
598
|
+
return {
|
|
599
|
+
state: "unavailable",
|
|
600
|
+
reason: `${CLI_PACKAGE_JSON_PATH} is missing at both ${baseRef} and ${headRef}.`,
|
|
601
|
+
baseRef,
|
|
602
|
+
headRef
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
if (!baseDoc.ok || !headDoc.ok) {
|
|
606
|
+
return {
|
|
607
|
+
state: "unavailable",
|
|
608
|
+
reason: "Unable to resolve package.json state at base/head refs.",
|
|
609
|
+
baseRef,
|
|
610
|
+
headRef
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
const baseBin = baseDoc.value.bin;
|
|
614
|
+
const headBin = headDoc.value.bin;
|
|
615
|
+
return {
|
|
616
|
+
state: isDeepStrictEqual(baseBin, headBin) ? "unchanged" : "changed",
|
|
617
|
+
baseRef,
|
|
618
|
+
headRef
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
function createDefaultWURuleResolvers() {
|
|
622
|
+
return {
|
|
623
|
+
pathReferenceExists,
|
|
624
|
+
resolveChangedFiles,
|
|
625
|
+
resolveCliBinDiff
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ../core/dist/wu-validation.js
|
|
630
|
+
var LANE_PARENTS = {
|
|
631
|
+
CONTENT: "content",
|
|
632
|
+
FRAMEWORK: "framework",
|
|
633
|
+
OPERATIONS: "operations"
|
|
634
|
+
};
|
|
635
|
+
function getLaneParent(lane) {
|
|
636
|
+
if (!lane || typeof lane !== "string")
|
|
637
|
+
return "";
|
|
638
|
+
return (lane.split(":")[0] ?? "").trim().toLowerCase();
|
|
639
|
+
}
|
|
640
|
+
function resolveExposureDefault(lane) {
|
|
641
|
+
const parent = getLaneParent(lane);
|
|
642
|
+
if (parent === LANE_PARENTS.CONTENT) {
|
|
643
|
+
return WU_EXPOSURE.DOCUMENTATION;
|
|
644
|
+
}
|
|
645
|
+
if (parent === LANE_PARENTS.FRAMEWORK || parent === LANE_PARENTS.OPERATIONS) {
|
|
646
|
+
return WU_EXPOSURE.BACKEND_ONLY;
|
|
647
|
+
}
|
|
648
|
+
return void 0;
|
|
649
|
+
}
|
|
650
|
+
var UI_VERIFICATION_KEYWORDS = [
|
|
651
|
+
"ui",
|
|
652
|
+
"frontend",
|
|
653
|
+
"component",
|
|
654
|
+
"widget",
|
|
655
|
+
"page",
|
|
656
|
+
"displays",
|
|
657
|
+
"shows",
|
|
658
|
+
"renders",
|
|
659
|
+
"user sees",
|
|
660
|
+
"visible",
|
|
661
|
+
"screen",
|
|
662
|
+
"interface"
|
|
663
|
+
];
|
|
664
|
+
var EXPOSURE_WARNING_MESSAGES = {
|
|
665
|
+
/**
|
|
666
|
+
* Warning when exposure field is missing entirely.
|
|
667
|
+
* @param {string} wuId - The WU identifier
|
|
668
|
+
* @returns {string} Warning message with remediation
|
|
669
|
+
*/
|
|
670
|
+
MISSING_EXPOSURE: (wuId) => `${wuId}: exposure field is missing. Add 'exposure: ui|api|backend-only|documentation' to the WU YAML. This helps ensure user-facing features have corresponding UI coverage.`,
|
|
671
|
+
/**
|
|
672
|
+
* Warning when API exposure lacks UI pairing WUs.
|
|
673
|
+
* @param {string} wuId - The WU identifier
|
|
674
|
+
* @returns {string} Warning message with remediation
|
|
675
|
+
*/
|
|
676
|
+
MISSING_UI_PAIRING: (wuId) => `${wuId}: exposure=api but ui_pairing_wus not specified. Add 'ui_pairing_wus: [WU-XXX]' listing UI WUs that consume this API, or set exposure to 'backend-only' if no UI is planned.`,
|
|
677
|
+
/**
|
|
678
|
+
* Warning when API exposure lacks UI verification in acceptance criteria.
|
|
679
|
+
* @param {string} wuId - The WU identifier
|
|
680
|
+
* @returns {string} Warning message with remediation
|
|
681
|
+
*/
|
|
682
|
+
MISSING_UI_VERIFICATION: (wuId) => `${wuId}: exposure=api but acceptance criteria lacks UI verification mention. Consider adding a criterion like 'UI displays the data correctly' to ensure end-to-end coverage.`,
|
|
683
|
+
/**
|
|
684
|
+
* Recommendation for user_journey when exposure is UI.
|
|
685
|
+
* @param {string} wuId - The WU identifier
|
|
686
|
+
* @returns {string} Warning message with remediation
|
|
687
|
+
*/
|
|
688
|
+
MISSING_USER_JOURNEY: (wuId) => `${wuId}: exposure=ui but user_journey field not present. Adding 'user_journey: "<description>"' is recommended to document the user flow.`
|
|
689
|
+
};
|
|
690
|
+
function hasUIVerificationInAcceptance(acceptance) {
|
|
691
|
+
let criteria;
|
|
692
|
+
if (Array.isArray(acceptance)) {
|
|
693
|
+
criteria = acceptance;
|
|
694
|
+
} else {
|
|
695
|
+
criteria = Object.values(acceptance).flat();
|
|
696
|
+
}
|
|
697
|
+
const lowerCriteria = criteria.map((c) => typeof c === "string" ? c.toLowerCase() : "");
|
|
698
|
+
return lowerCriteria.some((criterion) => UI_VERIFICATION_KEYWORDS.some((keyword) => criterion.includes(keyword.toLowerCase())));
|
|
699
|
+
}
|
|
700
|
+
function validateExposure(wu, options = {}) {
|
|
701
|
+
const warnings = [];
|
|
702
|
+
if (options.skipExposureCheck) {
|
|
703
|
+
return { valid: true, warnings: [] };
|
|
704
|
+
}
|
|
705
|
+
const wuId = wu.id || "WU-???";
|
|
706
|
+
const exposure = wu.exposure || resolveExposureDefault(wu.lane);
|
|
707
|
+
if (!exposure) {
|
|
708
|
+
warnings.push(EXPOSURE_WARNING_MESSAGES.MISSING_EXPOSURE(wuId));
|
|
709
|
+
return { valid: true, warnings };
|
|
710
|
+
}
|
|
711
|
+
if (exposure === WU_EXPOSURE.API) {
|
|
712
|
+
const uiPairingWus = wu.ui_pairing_wus;
|
|
713
|
+
if (!uiPairingWus || uiPairingWus.length === 0) {
|
|
714
|
+
warnings.push(EXPOSURE_WARNING_MESSAGES.MISSING_UI_PAIRING(wuId));
|
|
715
|
+
}
|
|
716
|
+
const acceptance = wu.acceptance;
|
|
717
|
+
if (acceptance && !hasUIVerificationInAcceptance(acceptance)) {
|
|
718
|
+
warnings.push(EXPOSURE_WARNING_MESSAGES.MISSING_UI_VERIFICATION(wuId));
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
if (exposure === WU_EXPOSURE.UI) {
|
|
722
|
+
if (!wu.user_journey) {
|
|
723
|
+
warnings.push(EXPOSURE_WARNING_MESSAGES.MISSING_USER_JOURNEY(wuId));
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return { valid: true, warnings };
|
|
727
|
+
}
|
|
728
|
+
var NAVIGATION_KEYWORDS = [
|
|
729
|
+
"navigate",
|
|
730
|
+
"navigation",
|
|
731
|
+
"accessible",
|
|
732
|
+
"access",
|
|
733
|
+
"visible",
|
|
734
|
+
"reachable",
|
|
735
|
+
"go to",
|
|
736
|
+
"visit",
|
|
737
|
+
"open",
|
|
738
|
+
"click",
|
|
739
|
+
"link",
|
|
740
|
+
"route",
|
|
741
|
+
"url",
|
|
742
|
+
"path",
|
|
743
|
+
"/space",
|
|
744
|
+
"/dashboard",
|
|
745
|
+
"/settings"
|
|
746
|
+
];
|
|
747
|
+
var PAGE_FILE_PATTERNS = [
|
|
748
|
+
/app\/.*\/page\.tsx$/,
|
|
749
|
+
/app\/.*\/page\.ts$/,
|
|
750
|
+
/pages\/.*\.tsx$/,
|
|
751
|
+
/pages\/.*\.ts$/
|
|
752
|
+
];
|
|
753
|
+
var ACCESSIBILITY_ERROR_MESSAGES = {
|
|
754
|
+
/**
|
|
755
|
+
* Error when UI exposure lacks navigation accessibility proof.
|
|
756
|
+
* @param {string} wuId - The WU identifier
|
|
757
|
+
* @returns {string} Error message with remediation guidance
|
|
758
|
+
*/
|
|
759
|
+
UI_NOT_ACCESSIBLE: (wuId) => `${wuId}: exposure=ui but feature accessibility not verified. Add one of the following:
|
|
760
|
+
1. navigation_path: '/your-route' - specify the route where feature is accessible
|
|
761
|
+
2. code_paths: [..., 'apps/web/src/app/.../page.tsx'] - include a page file
|
|
762
|
+
3. tests.manual: ['Navigate to /path and verify feature is accessible'] - add navigation test
|
|
763
|
+
|
|
764
|
+
This prevents "orphaned code" - features that exist but users cannot access. Use --skip-accessibility-check to bypass (not recommended).`
|
|
765
|
+
};
|
|
766
|
+
function hasPageFileInCodePaths(codePaths) {
|
|
767
|
+
if (!codePaths || !Array.isArray(codePaths)) {
|
|
768
|
+
return false;
|
|
769
|
+
}
|
|
770
|
+
return codePaths.some((codePath) => PAGE_FILE_PATTERNS.some((pattern) => pattern.test(codePath)));
|
|
771
|
+
}
|
|
772
|
+
function hasNavigationInManualTests(tests) {
|
|
773
|
+
if (!tests || typeof tests !== "object") {
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
const manualTests = tests.manual;
|
|
777
|
+
if (!manualTests || !Array.isArray(manualTests)) {
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
780
|
+
const lowerTests = manualTests.map((t) => typeof t === "string" ? t.toLowerCase() : "");
|
|
781
|
+
return lowerTests.some((test) => NAVIGATION_KEYWORDS.some((keyword) => test.includes(keyword.toLowerCase())));
|
|
782
|
+
}
|
|
783
|
+
function validateFeatureAccessibility(wu, options = {}) {
|
|
784
|
+
const errors = [];
|
|
785
|
+
if (options.skipAccessibilityCheck) {
|
|
786
|
+
return { valid: true, errors: [] };
|
|
787
|
+
}
|
|
788
|
+
const exposure = wu.exposure;
|
|
789
|
+
if (!exposure || exposure !== WU_EXPOSURE.UI) {
|
|
790
|
+
return { valid: true, errors: [] };
|
|
791
|
+
}
|
|
792
|
+
const wuId = wu.id || "WU-???";
|
|
793
|
+
if (wu.navigation_path && wu.navigation_path.trim().length > 0) {
|
|
794
|
+
return { valid: true, errors: [] };
|
|
795
|
+
}
|
|
796
|
+
if (hasPageFileInCodePaths(wu.code_paths)) {
|
|
797
|
+
return { valid: true, errors: [] };
|
|
798
|
+
}
|
|
799
|
+
if (hasNavigationInManualTests(wu.tests)) {
|
|
800
|
+
return { valid: true, errors: [] };
|
|
801
|
+
}
|
|
802
|
+
errors.push(ACCESSIBILITY_ERROR_MESSAGES.UI_NOT_ACCESSIBLE(wuId));
|
|
803
|
+
return { valid: false, errors };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// ../core/dist/file-classifiers.js
|
|
807
|
+
function ensureTrailingSlash(pathValue) {
|
|
808
|
+
const normalized = pathValue.replace(/\\/g, "/").trim();
|
|
809
|
+
if (!normalized) {
|
|
810
|
+
return normalized;
|
|
811
|
+
}
|
|
812
|
+
return normalized.endsWith("/") ? normalized : `${normalized}/`;
|
|
813
|
+
}
|
|
814
|
+
function getDocsOnlyPrefixes(options = {}) {
|
|
815
|
+
const directories = getConfig({ projectRoot: options.projectRoot }).directories;
|
|
816
|
+
const prefixes = [directories.docs, directories.ai, directories.claude, directories.memoryBank].map(ensureTrailingSlash).filter((prefix) => prefix.length > 0);
|
|
817
|
+
return Object.freeze(Array.from(new Set(prefixes)));
|
|
818
|
+
}
|
|
819
|
+
var DOCS_ONLY_ROOT_FILES = Object.freeze(["readme", "claude"]);
|
|
820
|
+
var TEST_FILE_PATTERNS = Object.freeze([
|
|
821
|
+
/\.test\.(ts|tsx|js|jsx|mjs)$/,
|
|
822
|
+
/\.spec\.(ts|tsx|js|jsx|mjs)$/,
|
|
823
|
+
/__tests__\//,
|
|
824
|
+
/\.test-utils\./,
|
|
825
|
+
/\.mock\./
|
|
826
|
+
]);
|
|
827
|
+
function isMarkdownFile(filePath) {
|
|
828
|
+
if (!filePath || typeof filePath !== "string") {
|
|
829
|
+
return false;
|
|
830
|
+
}
|
|
831
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
832
|
+
return /\.md$/i.test(normalized);
|
|
833
|
+
}
|
|
834
|
+
function isDocumentationPath(filePath) {
|
|
835
|
+
if (!filePath || typeof filePath !== "string") {
|
|
836
|
+
return false;
|
|
837
|
+
}
|
|
838
|
+
const path4 = filePath.trim();
|
|
839
|
+
if (path4.length === 0) {
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
for (const prefix of getDocsOnlyPrefixes()) {
|
|
843
|
+
if (path4.startsWith(prefix)) {
|
|
844
|
+
return true;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (isMarkdownFile(path4)) {
|
|
848
|
+
const lowerPath = path4.toLowerCase();
|
|
849
|
+
for (const pattern of DOCS_ONLY_ROOT_FILES) {
|
|
850
|
+
if (lowerPath.startsWith(pattern)) {
|
|
851
|
+
return true;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
return false;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ../core/dist/wu-done-validation.js
|
|
859
|
+
var GIT_TREE_LIST_ARGS = ["-r", "--name-only"];
|
|
860
|
+
var GLOB_MATCH_OPTIONS = { dot: true };
|
|
861
|
+
function applyExposureDefaults(doc) {
|
|
862
|
+
if (!doc || typeof doc !== "object") {
|
|
863
|
+
return { applied: false };
|
|
864
|
+
}
|
|
865
|
+
if (typeof doc.exposure === "string" && doc.exposure.trim().length > 0) {
|
|
866
|
+
return { applied: false, exposure: doc.exposure };
|
|
867
|
+
}
|
|
868
|
+
const exposureDefault = resolveExposureDefault(doc.lane);
|
|
869
|
+
if (!exposureDefault) {
|
|
870
|
+
return { applied: false };
|
|
871
|
+
}
|
|
872
|
+
doc.exposure = exposureDefault;
|
|
873
|
+
return { applied: true, exposure: exposureDefault };
|
|
874
|
+
}
|
|
875
|
+
async function validateCodePathsExist(doc, id, options = {}) {
|
|
876
|
+
const { targetBranch = BRANCHES.MAIN, worktreePath = null } = options;
|
|
877
|
+
const errors = [];
|
|
878
|
+
const missing = [];
|
|
879
|
+
const codePaths = Array.isArray(doc.code_paths) ? doc.code_paths.filter((entry) => typeof entry === "string") : [];
|
|
880
|
+
if (codePaths.length === 0) {
|
|
881
|
+
console.log(`${LOG_PREFIX.DONE} ${EMOJI.INFO} No code_paths to validate for ${id}`);
|
|
882
|
+
return { valid: true, errors: [], missing: [] };
|
|
883
|
+
}
|
|
884
|
+
console.log(`${LOG_PREFIX.DONE} Validating ${codePaths.length} code_paths exist...`);
|
|
885
|
+
if (worktreePath && existsSync2(worktreePath)) {
|
|
886
|
+
for (const filePath of codePaths) {
|
|
887
|
+
const existsInWorktree = hasGlobPattern(filePath) ? pathReferenceExistsSync(filePath, worktreePath) : existsSync2(path3.join(worktreePath, filePath));
|
|
888
|
+
if (!existsInWorktree) {
|
|
889
|
+
missing.push(filePath);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
if (missing.length > 0) {
|
|
893
|
+
errors.push(`code_paths validation failed - ${missing.length} file(s) not found in worktree:
|
|
894
|
+
${missing.map((p) => ` - ${p}`).join(STRING_LITERALS.NEWLINE)}
|
|
895
|
+
|
|
896
|
+
Ensure all files listed in code_paths exist before running wu:done.`);
|
|
897
|
+
}
|
|
898
|
+
} else {
|
|
899
|
+
try {
|
|
900
|
+
const gitAdapter = getGitForCwd();
|
|
901
|
+
const branchFileListOutput = await gitAdapter.raw([
|
|
902
|
+
GIT_COMMANDS.LS_TREE,
|
|
903
|
+
...GIT_TREE_LIST_ARGS,
|
|
904
|
+
targetBranch
|
|
905
|
+
]);
|
|
906
|
+
const branchFiles = branchFileListOutput.split(STRING_LITERALS.NEWLINE).map((entry) => entry.trim()).filter(Boolean);
|
|
907
|
+
for (const filePath of codePaths) {
|
|
908
|
+
if (hasGlobPattern(filePath)) {
|
|
909
|
+
const hasGlobMatch = branchFiles.some((branchFile) => minimatch2(branchFile, filePath, GLOB_MATCH_OPTIONS));
|
|
910
|
+
if (!hasGlobMatch) {
|
|
911
|
+
missing.push(filePath);
|
|
912
|
+
}
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
try {
|
|
916
|
+
const result = await gitAdapter.raw([GIT_COMMANDS.LS_TREE, targetBranch, "--", filePath]);
|
|
917
|
+
if (!result || result.trim() === "") {
|
|
918
|
+
missing.push(filePath);
|
|
919
|
+
}
|
|
920
|
+
} catch {
|
|
921
|
+
missing.push(filePath);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
if (missing.length > 0) {
|
|
925
|
+
errors.push(`code_paths validation failed - ${missing.length} file(s) not found on ${targetBranch}:
|
|
926
|
+
${missing.map((p) => ` - ${p}`).join(STRING_LITERALS.NEWLINE)}
|
|
927
|
+
|
|
928
|
+
\u274C POTENTIAL FALSE COMPLETION DETECTED
|
|
929
|
+
|
|
930
|
+
These files are listed in code_paths but do not exist on ${targetBranch}.
|
|
931
|
+
This prevents creating a stamp for incomplete work.
|
|
932
|
+
|
|
933
|
+
Fix options:
|
|
934
|
+
1. Ensure all code is committed and merged to ${targetBranch}
|
|
935
|
+
2. Update code_paths in ${id}.yaml to match actual files
|
|
936
|
+
3. Remove files that were intentionally not created
|
|
937
|
+
|
|
938
|
+
Context: WU-1351 prevents false completions from INIT-WORKFLOW-INTEGRITY`);
|
|
939
|
+
}
|
|
940
|
+
} catch (err) {
|
|
941
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
942
|
+
console.warn(`${LOG_PREFIX.DONE} ${EMOJI.WARNING} Could not validate code_paths: ${message}`);
|
|
943
|
+
return { valid: true, errors: [], missing: [] };
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
if (errors.length > 0) {
|
|
947
|
+
return { valid: false, errors, missing };
|
|
948
|
+
}
|
|
949
|
+
console.log(`${LOG_PREFIX.DONE} ${EMOJI.SUCCESS} All ${codePaths.length} code_paths verified`);
|
|
950
|
+
return { valid: true, errors: [], missing: [] };
|
|
951
|
+
}
|
|
952
|
+
function validateSpecCompleteness(doc, _id) {
|
|
953
|
+
const errors = [];
|
|
954
|
+
if (doc.description && doc.description.includes(PLACEHOLDER_SENTINEL)) {
|
|
955
|
+
errors.push(`Description contains ${PLACEHOLDER_SENTINEL} marker`);
|
|
956
|
+
}
|
|
957
|
+
if (doc.acceptance) {
|
|
958
|
+
const hasPlaceholder = (value) => {
|
|
959
|
+
if (typeof value === "string") {
|
|
960
|
+
return value.includes(PLACEHOLDER_SENTINEL);
|
|
961
|
+
}
|
|
962
|
+
if (Array.isArray(value)) {
|
|
963
|
+
return value.some((item) => hasPlaceholder(item));
|
|
964
|
+
}
|
|
965
|
+
if (typeof value === "object" && value !== null) {
|
|
966
|
+
return Object.values(value).some((item) => hasPlaceholder(item));
|
|
967
|
+
}
|
|
968
|
+
return false;
|
|
969
|
+
};
|
|
970
|
+
if (hasPlaceholder(doc.acceptance)) {
|
|
971
|
+
errors.push(`Acceptance criteria contain ${PLACEHOLDER_SENTINEL} markers`);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
if (!doc.description || doc.description.trim().length < VALIDATION.MIN_DESCRIPTION_LENGTH) {
|
|
975
|
+
errors.push(`Description too short (${doc.description?.trim().length || 0} chars, minimum ${VALIDATION.MIN_DESCRIPTION_LENGTH})`);
|
|
976
|
+
}
|
|
977
|
+
if (doc.type !== WU_TYPES.DOCUMENTATION && doc.type !== WU_TYPES.PROCESS) {
|
|
978
|
+
const codePaths = Array.isArray(doc.code_paths) ? doc.code_paths : [];
|
|
979
|
+
if (codePaths.length === 0) {
|
|
980
|
+
errors.push("Code paths required for non-documentation WUs");
|
|
981
|
+
}
|
|
982
|
+
const testObj = doc.tests && typeof doc.tests === "object" ? doc.tests : doc.test_paths && typeof doc.test_paths === "object" ? doc.test_paths : {};
|
|
983
|
+
const hasItems = (arr) => Array.isArray(arr) && arr.length > 0;
|
|
984
|
+
const hasUnitTests = hasItems(testObj[TEST_TYPES.UNIT]);
|
|
985
|
+
const hasE2ETests = hasItems(testObj[TEST_TYPES.E2E]);
|
|
986
|
+
const hasManualTests = hasItems(testObj[TEST_TYPES.MANUAL]);
|
|
987
|
+
const hasIntegrationTests = hasItems(testObj[TEST_TYPES.INTEGRATION]);
|
|
988
|
+
if (!(hasUnitTests || hasE2ETests || hasManualTests || hasIntegrationTests)) {
|
|
989
|
+
errors.push("At least one test path required (unit, e2e, integration, or manual)");
|
|
990
|
+
}
|
|
991
|
+
const automatedTestResult = validateAutomatedTestRequirement(doc);
|
|
992
|
+
if (!automatedTestResult.valid) {
|
|
993
|
+
errors.push(...automatedTestResult.errors);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return { valid: errors.length === 0, errors };
|
|
997
|
+
}
|
|
998
|
+
function deriveStatusFromEventsContent(eventsContent, wuId) {
|
|
999
|
+
let status;
|
|
1000
|
+
for (const line of eventsContent.split("\n")) {
|
|
1001
|
+
if (!line.trim())
|
|
1002
|
+
continue;
|
|
1003
|
+
try {
|
|
1004
|
+
const event = JSON.parse(line);
|
|
1005
|
+
if (event.wuId !== wuId || !event.type)
|
|
1006
|
+
continue;
|
|
1007
|
+
switch (event.type) {
|
|
1008
|
+
case "claim":
|
|
1009
|
+
case "create":
|
|
1010
|
+
status = WU_STATUS.IN_PROGRESS;
|
|
1011
|
+
break;
|
|
1012
|
+
case "release":
|
|
1013
|
+
status = WU_STATUS.READY;
|
|
1014
|
+
break;
|
|
1015
|
+
case "complete":
|
|
1016
|
+
status = WU_STATUS.DONE;
|
|
1017
|
+
break;
|
|
1018
|
+
case "block":
|
|
1019
|
+
status = WU_STATUS.BLOCKED;
|
|
1020
|
+
break;
|
|
1021
|
+
case "unblock":
|
|
1022
|
+
status = WU_STATUS.IN_PROGRESS;
|
|
1023
|
+
break;
|
|
1024
|
+
}
|
|
1025
|
+
} catch {
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
return status;
|
|
1029
|
+
}
|
|
1030
|
+
function validatePostMutation({ id, wuPath, stampPath, eventsPath = null }) {
|
|
1031
|
+
const errors = [];
|
|
1032
|
+
if (!existsSync2(stampPath)) {
|
|
1033
|
+
errors.push(`Stamp file not created: ${stampPath}`);
|
|
1034
|
+
}
|
|
1035
|
+
if (!existsSync2(wuPath)) {
|
|
1036
|
+
errors.push(`WU YAML not found after mutation: ${wuPath}`);
|
|
1037
|
+
return { valid: false, errors };
|
|
1038
|
+
}
|
|
1039
|
+
try {
|
|
1040
|
+
const content = readFileSync(wuPath, { encoding: FILE_SYSTEM.ENCODING });
|
|
1041
|
+
const doc = parseYAML(content);
|
|
1042
|
+
if (!doc.completed_at) {
|
|
1043
|
+
errors.push(`Missing required field 'completed_at' in ${id}.yaml`);
|
|
1044
|
+
} else {
|
|
1045
|
+
const timestamp = new Date(doc.completed_at);
|
|
1046
|
+
if (isNaN(timestamp.getTime())) {
|
|
1047
|
+
errors.push(`Invalid completed_at timestamp: ${doc.completed_at}`);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
if (!doc.completed) {
|
|
1051
|
+
errors.push(`Missing required field 'completed' in ${id}.yaml`);
|
|
1052
|
+
} else {
|
|
1053
|
+
const normalizedCompleted = normalizeToDateString(doc.completed);
|
|
1054
|
+
if (!normalizedCompleted) {
|
|
1055
|
+
errors.push(`Invalid completed date: ${doc.completed}`);
|
|
1056
|
+
} else if (normalizedCompleted !== doc.completed) {
|
|
1057
|
+
errors.push(`Non-normalized completed date: ${doc.completed}`);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
if (doc.locked !== true) {
|
|
1061
|
+
errors.push(`Missing or invalid 'locked' field in ${id}.yaml (expected: true, got: ${doc.locked})`);
|
|
1062
|
+
}
|
|
1063
|
+
if (doc.status !== WU_STATUS.DONE) {
|
|
1064
|
+
errors.push(`Invalid status in ${id}.yaml (expected: '${WU_STATUS.DONE}', got: '${doc.status}')`);
|
|
1065
|
+
}
|
|
1066
|
+
} catch (err) {
|
|
1067
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1068
|
+
errors.push(`Failed to parse WU YAML after mutation: ${message}`);
|
|
1069
|
+
}
|
|
1070
|
+
if (eventsPath) {
|
|
1071
|
+
if (!existsSync2(eventsPath)) {
|
|
1072
|
+
errors.push(`State store file not found after mutation: ${eventsPath}`);
|
|
1073
|
+
} else {
|
|
1074
|
+
try {
|
|
1075
|
+
const eventsContent = readFileSync(eventsPath, {
|
|
1076
|
+
encoding: FILE_SYSTEM.ENCODING
|
|
1077
|
+
});
|
|
1078
|
+
const derivedStatus = deriveStatusFromEventsContent(eventsContent, id);
|
|
1079
|
+
if (derivedStatus !== WU_STATUS.DONE) {
|
|
1080
|
+
errors.push(`WU ${id} state store is '${derivedStatus ?? "missing"}' after mutation (expected: '${WU_STATUS.DONE}')`);
|
|
1081
|
+
}
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1084
|
+
errors.push(`Failed to parse state store after mutation: ${message}`);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
return { valid: errors.length === 0, errors };
|
|
1089
|
+
}
|
|
1090
|
+
function validateTestPathsRequired(wu) {
|
|
1091
|
+
const wuId = typeof wu.id === "string" ? wu.id : "WU-unknown";
|
|
1092
|
+
if (wu.type === WU_TYPES.DOCUMENTATION || wu.type === WU_TYPES.PROCESS) {
|
|
1093
|
+
return { valid: true };
|
|
1094
|
+
}
|
|
1095
|
+
const codePaths = Array.isArray(wu.code_paths) ? wu.code_paths.filter((entry) => typeof entry === "string") : [];
|
|
1096
|
+
if (codePaths.length === 0) {
|
|
1097
|
+
return { valid: true };
|
|
1098
|
+
}
|
|
1099
|
+
const hasCodeChanges = codePaths.some((codePath) => !isDocumentationPath(codePath));
|
|
1100
|
+
if (!hasCodeChanges) {
|
|
1101
|
+
return { valid: true };
|
|
1102
|
+
}
|
|
1103
|
+
const testObj = wu.tests && typeof wu.tests === "object" ? wu.tests : {};
|
|
1104
|
+
const hasItems = (arr) => Array.isArray(arr) && arr.length > 0;
|
|
1105
|
+
const hasUnitTests = hasItems(testObj[TEST_TYPES.UNIT]);
|
|
1106
|
+
const hasE2ETests = hasItems(testObj[TEST_TYPES.E2E]);
|
|
1107
|
+
const hasManualTests = hasItems(testObj[TEST_TYPES.MANUAL]);
|
|
1108
|
+
const hasIntegrationTests = hasItems(testObj[TEST_TYPES.INTEGRATION]);
|
|
1109
|
+
if (!(hasUnitTests || hasE2ETests || hasManualTests || hasIntegrationTests)) {
|
|
1110
|
+
return {
|
|
1111
|
+
valid: false,
|
|
1112
|
+
error: `${wuId} requires test_paths: WU has code_paths but no tests specified. Add unit, e2e, integration, or manual tests.`
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
const automatedTestResult = validateAutomatedTestRequirement(wu);
|
|
1116
|
+
if (!automatedTestResult.valid) {
|
|
1117
|
+
const errorSummary = automatedTestResult.errors[0]?.split("\n")[0] || "Automated tests required";
|
|
1118
|
+
return {
|
|
1119
|
+
valid: false,
|
|
1120
|
+
error: `${wuId}: ${errorSummary}`
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
return { valid: true };
|
|
1124
|
+
}
|
|
1125
|
+
function isAllowedDocsPath(filePath) {
|
|
1126
|
+
if (!filePath || typeof filePath !== "string")
|
|
1127
|
+
return false;
|
|
1128
|
+
const normalized = filePath.replace(/\\/g, "/").trim();
|
|
1129
|
+
if (normalized.length === 0)
|
|
1130
|
+
return false;
|
|
1131
|
+
if (normalized.startsWith(".lumenflow/stamps/")) {
|
|
1132
|
+
return true;
|
|
1133
|
+
}
|
|
1134
|
+
return isDocumentationPath(normalized);
|
|
1135
|
+
}
|
|
1136
|
+
function validateTypeVsCodePathsPreflight(wu) {
|
|
1137
|
+
const errors = [];
|
|
1138
|
+
const blockedPaths = [];
|
|
1139
|
+
const wuId = typeof wu.id === "string" ? wu.id : "WU-unknown";
|
|
1140
|
+
if (wu.type !== WU_TYPES.DOCUMENTATION) {
|
|
1141
|
+
return { valid: true, errors: [], blockedPaths: [], abortedBeforeTransaction: false };
|
|
1142
|
+
}
|
|
1143
|
+
const codePaths = wu.code_paths;
|
|
1144
|
+
if (!codePaths || !Array.isArray(codePaths) || codePaths.length === 0) {
|
|
1145
|
+
return { valid: true, errors: [], blockedPaths: [], abortedBeforeTransaction: false };
|
|
1146
|
+
}
|
|
1147
|
+
for (const filePath of codePaths) {
|
|
1148
|
+
if (!isAllowedDocsPath(filePath)) {
|
|
1149
|
+
blockedPaths.push(filePath);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
if (blockedPaths.length > 0) {
|
|
1153
|
+
const pathsList = blockedPaths.map((p) => ` - ${p}`).join("\n");
|
|
1154
|
+
errors.push(`Documentation WU ${wuId} has code_paths that would fail pre-commit hook:
|
|
1155
|
+
${pathsList}`);
|
|
1156
|
+
return { valid: false, errors, blockedPaths, abortedBeforeTransaction: true };
|
|
1157
|
+
}
|
|
1158
|
+
return { valid: true, errors: [], blockedPaths: [], abortedBeforeTransaction: false };
|
|
1159
|
+
}
|
|
1160
|
+
function buildTypeVsCodePathsErrorMessage(id, blockedPaths) {
|
|
1161
|
+
return `
|
|
1162
|
+
PREFLIGHT VALIDATION FAILED (WU-2310)
|
|
1163
|
+
|
|
1164
|
+
WU ${id} is type: documentation but has code_paths that are not allowed:
|
|
1165
|
+
|
|
1166
|
+
${blockedPaths.map((p) => ` - ${p}`).join("\n")}
|
|
1167
|
+
|
|
1168
|
+
This would fail at git commit time (pre-commit hook: gateDocsOnlyPathEnforcement).
|
|
1169
|
+
Aborting BEFORE transaction to prevent inconsistent state.
|
|
1170
|
+
|
|
1171
|
+
Fix options:
|
|
1172
|
+
|
|
1173
|
+
1. Change WU type to 'engineering' (or 'feature', 'bug', etc.):
|
|
1174
|
+
pnpm wu:edit --id ${id} --type engineering
|
|
1175
|
+
|
|
1176
|
+
2. Update code_paths to only include documentation files:
|
|
1177
|
+
pnpm wu:edit --id ${id} --code-paths "<docs-dir>/..." "*.md"
|
|
1178
|
+
|
|
1179
|
+
Allowed paths for documentation WUs:
|
|
1180
|
+
- configured docs-only prefixes from workspace.yaml software_delivery.directories
|
|
1181
|
+
(docs, ai, claude, memoryBank)
|
|
1182
|
+
- .lumenflow/stamps/
|
|
1183
|
+
- *.md files
|
|
1184
|
+
|
|
1185
|
+
After fixing, retry: pnpm wu:done --id ${id}
|
|
1186
|
+
`;
|
|
1187
|
+
}
|
|
1188
|
+
async function validateCodePathsCommittedBeforeDone(wu, gitAdapter, options = {}) {
|
|
1189
|
+
const { abortOnFailure = true } = options;
|
|
1190
|
+
const errors = [];
|
|
1191
|
+
const uncommittedPaths = [];
|
|
1192
|
+
const codePaths = wu.code_paths;
|
|
1193
|
+
if (!codePaths || codePaths.length === 0) {
|
|
1194
|
+
return { valid: true, errors: [], uncommittedPaths: [] };
|
|
1195
|
+
}
|
|
1196
|
+
try {
|
|
1197
|
+
const gitStatus = await gitAdapter.getStatus();
|
|
1198
|
+
const statusLines = gitStatus.split("\n").filter((line) => line.trim());
|
|
1199
|
+
const uncommittedFiles = /* @__PURE__ */ new Set();
|
|
1200
|
+
for (const line of statusLines) {
|
|
1201
|
+
const match = line.match(/^.{2}\s+(.+)$/);
|
|
1202
|
+
if (match && match[1]) {
|
|
1203
|
+
const filePath = match[1];
|
|
1204
|
+
uncommittedFiles.add(filePath);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
for (const codePath of codePaths) {
|
|
1208
|
+
if (uncommittedFiles.has(codePath)) {
|
|
1209
|
+
uncommittedPaths.push(codePath);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
if (uncommittedPaths.length > 0) {
|
|
1213
|
+
const count = uncommittedPaths.length;
|
|
1214
|
+
const pathList = uncommittedPaths.map((p) => ` - ${p}`).join("\n");
|
|
1215
|
+
errors.push(`${count} code_path${count === 1 ? "" : "s"} are not committed:
|
|
1216
|
+
${pathList}`);
|
|
1217
|
+
if (abortOnFailure) {
|
|
1218
|
+
const errorMessage = buildCodePathsCommittedErrorMessage(wu.id, uncommittedPaths);
|
|
1219
|
+
die(errorMessage);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
console.warn(`${LOG_PREFIX.DONE} ${EMOJI.WARNING} Could not validate code_paths commit status: ${err instanceof Error ? err.message : String(err)}`);
|
|
1224
|
+
return { valid: true, errors: [], uncommittedPaths: [] };
|
|
1225
|
+
}
|
|
1226
|
+
return {
|
|
1227
|
+
valid: errors.length === 0,
|
|
1228
|
+
errors,
|
|
1229
|
+
uncommittedPaths
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
function buildCodePathsCommittedErrorMessage(wuId, uncommittedPaths) {
|
|
1233
|
+
const count = uncommittedPaths.length;
|
|
1234
|
+
const pathList = uncommittedPaths.map((p) => ` - ${p}`).join("\n");
|
|
1235
|
+
return `
|
|
1236
|
+
\u274C UNCOMMITTED CODE_PATHS DETECTED (WU-1153)
|
|
1237
|
+
|
|
1238
|
+
${count} code_path${count === 1 ? "" : "s"} for ${wuId} are not committed:
|
|
1239
|
+
|
|
1240
|
+
${pathList}
|
|
1241
|
+
|
|
1242
|
+
wu:done cannot proceed because uncommitted code_paths would be lost
|
|
1243
|
+
if the metadata transaction fails and needs to roll back.
|
|
1244
|
+
|
|
1245
|
+
This prevents lost work from metadata rollbacks after code commits.
|
|
1246
|
+
|
|
1247
|
+
Required actions:
|
|
1248
|
+
1. Commit your code changes:
|
|
1249
|
+
git add ${uncommittedPaths.join(" ")}
|
|
1250
|
+
git commit -m "implement: ${wuId} changes"
|
|
1251
|
+
|
|
1252
|
+
2. Retry wu:done:
|
|
1253
|
+
pnpm wu:done --id ${wuId}
|
|
1254
|
+
|
|
1255
|
+
The guard ensures atomic completion: either both code and metadata succeed,
|
|
1256
|
+
or neither is modified. This prevents partial state corruption.
|
|
1257
|
+
|
|
1258
|
+
Context: WU-1153 prevents lost work from metadata rollbacks
|
|
1259
|
+
`;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
export {
|
|
1263
|
+
getDocsOnlyPrefixes,
|
|
1264
|
+
DOCS_ONLY_ROOT_FILES,
|
|
1265
|
+
TEST_FILE_PATTERNS,
|
|
1266
|
+
RULE_CODES,
|
|
1267
|
+
REGISTRATION_SURFACES,
|
|
1268
|
+
isCodePathCoveredByChangedFiles,
|
|
1269
|
+
findMissingCodePathCoverage,
|
|
1270
|
+
validateWURulesSync,
|
|
1271
|
+
validateWURulesWithResolvers,
|
|
1272
|
+
resolveChangedFiles,
|
|
1273
|
+
createDefaultWURuleResolvers,
|
|
1274
|
+
validateExposure,
|
|
1275
|
+
validateFeatureAccessibility,
|
|
1276
|
+
applyExposureDefaults,
|
|
1277
|
+
validateCodePathsExist,
|
|
1278
|
+
validateSpecCompleteness,
|
|
1279
|
+
validatePostMutation,
|
|
1280
|
+
validateTestPathsRequired,
|
|
1281
|
+
validateTypeVsCodePathsPreflight,
|
|
1282
|
+
buildTypeVsCodePathsErrorMessage,
|
|
1283
|
+
validateCodePathsCommittedBeforeDone,
|
|
1284
|
+
buildCodePathsCommittedErrorMessage
|
|
1285
|
+
};
|