cclaw-cli 0.48.17 → 0.48.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/content/node-hooks.js +43 -15
- package/dist/fs-utils.js +56 -24
- package/dist/internal/tdd-red-evidence.js +28 -2
- package/dist/tdd-cycle.d.ts +22 -1
- package/dist/tdd-cycle.js +62 -26
- package/package.json +1 -1
|
@@ -118,26 +118,54 @@ async function writeFileAtomic(filePath, content, options = {}) {
|
|
|
118
118
|
"." + path.basename(filePath) + ".tmp-" + process.pid + "-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8)
|
|
119
119
|
);
|
|
120
120
|
await fs.writeFile(tempPath, content, { encoding: "utf8" });
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
} finally {
|
|
132
|
-
await fs.unlink(tempPath).catch(() => undefined);
|
|
133
|
-
}
|
|
121
|
+
// Windows' fs.rename can fail transiently with EPERM/EBUSY/EACCES when the
|
|
122
|
+
// destination file is held open by another process (antivirus, indexer,
|
|
123
|
+
// or a sibling hook invocation racing on the same file). Retry with tiny
|
|
124
|
+
// backoff before falling back to copyFile.
|
|
125
|
+
const renameRetryableCodes = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
126
|
+
let attempt = 0;
|
|
127
|
+
const maxAttempts = 6;
|
|
128
|
+
while (true) {
|
|
129
|
+
try {
|
|
130
|
+
await fs.rename(tempPath, filePath);
|
|
134
131
|
if (options.mode !== undefined) {
|
|
135
132
|
await fs.chmod(filePath, options.mode).catch(() => undefined);
|
|
136
133
|
}
|
|
137
134
|
return;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const code = error && typeof error === "object" && "code" in error ? error.code : null;
|
|
137
|
+
if (code === "EXDEV") {
|
|
138
|
+
try {
|
|
139
|
+
await fs.copyFile(tempPath, filePath);
|
|
140
|
+
} finally {
|
|
141
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
142
|
+
}
|
|
143
|
+
if (options.mode !== undefined) {
|
|
144
|
+
await fs.chmod(filePath, options.mode).catch(() => undefined);
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (renameRetryableCodes.has(code) && attempt < maxAttempts) {
|
|
149
|
+
attempt += 1;
|
|
150
|
+
await hookSleep(10 * attempt + Math.floor(Math.random() * 10));
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (renameRetryableCodes.has(code)) {
|
|
154
|
+
// Last-resort fallback: copy-then-unlink. Not atomic, but the
|
|
155
|
+
// directory lock around this call already serializes writers.
|
|
156
|
+
try {
|
|
157
|
+
await fs.copyFile(tempPath, filePath);
|
|
158
|
+
if (options.mode !== undefined) {
|
|
159
|
+
await fs.chmod(filePath, options.mode).catch(() => undefined);
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
} finally {
|
|
163
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
167
|
+
throw error;
|
|
138
168
|
}
|
|
139
|
-
await fs.unlink(tempPath).catch(() => undefined);
|
|
140
|
-
throw error;
|
|
141
169
|
}
|
|
142
170
|
}
|
|
143
171
|
|
package/dist/fs-utils.js
CHANGED
|
@@ -75,35 +75,67 @@ export async function writeFileSafe(filePath, content, options = {}) {
|
|
|
75
75
|
const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
76
76
|
const targetMode = options.mode;
|
|
77
77
|
await fs.writeFile(tempPath, content, { encoding: "utf8", ...(targetMode !== undefined ? { mode: targetMode } : {}) });
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
// On Windows, `fs.rename` can fail transiently with EPERM / EBUSY / EACCES
|
|
79
|
+
// when the target file is briefly held open by another process (antivirus,
|
|
80
|
+
// search indexer, or a concurrent cclaw hook). Retry with small backoff
|
|
81
|
+
// before falling back to a non-atomic copy + unlink.
|
|
82
|
+
const renameRetryableCodes = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
83
|
+
let attempt = 0;
|
|
84
|
+
const maxAttempts = 6;
|
|
85
|
+
// eslint-disable-next-line no-constant-condition
|
|
86
|
+
while (true) {
|
|
87
|
+
try {
|
|
88
|
+
await fs.rename(tempPath, filePath);
|
|
89
|
+
if (targetMode !== undefined) {
|
|
90
|
+
await fs.chmod(filePath, targetMode).catch(() => undefined);
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
82
93
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
catch (error) {
|
|
95
|
+
const code = error?.code;
|
|
96
|
+
// `rename` fails with EXDEV when the temp file and target live on
|
|
97
|
+
// different filesystems (container bind mounts, tmpfs + rootfs,
|
|
98
|
+
// cross-volume setups). Fall back to copy + unlink so atomic writes
|
|
99
|
+
// still work — copyFile is not fully atomic but is the best we can
|
|
100
|
+
// do across devices, and we remove the temp even if copy fails.
|
|
101
|
+
if (code === "EXDEV") {
|
|
102
|
+
try {
|
|
103
|
+
await fs.copyFile(tempPath, filePath);
|
|
104
|
+
if (targetMode !== undefined) {
|
|
105
|
+
await fs.chmod(filePath, targetMode).catch(() => undefined);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
96
110
|
}
|
|
111
|
+
return;
|
|
97
112
|
}
|
|
98
|
-
|
|
99
|
-
|
|
113
|
+
if (code !== undefined && renameRetryableCodes.has(code) && attempt < maxAttempts) {
|
|
114
|
+
attempt += 1;
|
|
115
|
+
const waitMs = 10 * attempt + Math.floor(Math.random() * 10);
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
117
|
+
continue;
|
|
100
118
|
}
|
|
101
|
-
|
|
119
|
+
if (code !== undefined && renameRetryableCodes.has(code)) {
|
|
120
|
+
// Last-resort fallback on Windows: copy-then-unlink. Not atomic,
|
|
121
|
+
// but the caller is expected to have serialized via a directory
|
|
122
|
+
// lock so this only loses atomicity under extreme contention.
|
|
123
|
+
try {
|
|
124
|
+
await fs.copyFile(tempPath, filePath);
|
|
125
|
+
if (targetMode !== undefined) {
|
|
126
|
+
await fs.chmod(filePath, targetMode).catch(() => undefined);
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Other errors: try to clean up the temp to avoid littering the
|
|
135
|
+
// directory with orphaned `.tmp-<pid>-*` files, then rethrow.
|
|
136
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
137
|
+
throw error;
|
|
102
138
|
}
|
|
103
|
-
// Other errors: try to clean up the temp to avoid littering the
|
|
104
|
-
// directory with orphaned `.tmp-<pid>-*` files, then rethrow.
|
|
105
|
-
await fs.unlink(tempPath).catch(() => undefined);
|
|
106
|
-
throw error;
|
|
107
139
|
}
|
|
108
140
|
}
|
|
109
141
|
export async function exists(filePath) {
|
|
@@ -89,14 +89,40 @@ function hasFailingAutoEvidenceForPath(entries, targetPath, options = {}) {
|
|
|
89
89
|
export async function runTddRedEvidenceCommand(projectRoot, tokens, io) {
|
|
90
90
|
const args = parseArgs(tokens);
|
|
91
91
|
const flowState = await readFlowState(projectRoot).catch(() => null);
|
|
92
|
+
// Strict runId scoping: a previous implementation fell back to no
|
|
93
|
+
// filter when both `--runId` and `flowState.activeRunId` were missing,
|
|
94
|
+
// which let evidence rows from past runs satisfy the current check
|
|
95
|
+
// (false positive). Now: require an explicit or inferred runId or
|
|
96
|
+
// fail loud so the caller cannot silently inherit cross-run state.
|
|
92
97
|
const effectiveRunId = args.runId ?? flowState?.activeRunId;
|
|
98
|
+
if (!effectiveRunId || effectiveRunId.trim().length === 0) {
|
|
99
|
+
const reason = "tdd-red-evidence: cannot scope check — no --runId provided and " +
|
|
100
|
+
"flow-state.json has no activeRunId. Pass --runId=<id> explicitly " +
|
|
101
|
+
"or run `cclaw doctor` to reconcile state.";
|
|
102
|
+
if (!args.quiet) {
|
|
103
|
+
io.stdout.write(`${JSON.stringify({
|
|
104
|
+
ok: false,
|
|
105
|
+
path: args.targetPath,
|
|
106
|
+
runId: null,
|
|
107
|
+
error: reason,
|
|
108
|
+
sources: { tddCycleLog: false, autoEvidence: false }
|
|
109
|
+
}, null, 2)}\n`);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
io.stderr.write(`${reason}\n`);
|
|
113
|
+
}
|
|
114
|
+
return 2;
|
|
115
|
+
}
|
|
93
116
|
const tddLogPath = path.join(projectRoot, RUNTIME_ROOT, "state", "tdd-cycle-log.jsonl");
|
|
94
117
|
const autoEvidencePath = path.join(projectRoot, RUNTIME_ROOT, "state", "tdd-red-evidence.jsonl");
|
|
95
118
|
let cycleLogHasRed = false;
|
|
96
119
|
let autoEvidenceHasRed = false;
|
|
97
120
|
try {
|
|
98
121
|
const raw = await fs.readFile(tddLogPath, "utf8");
|
|
99
|
-
|
|
122
|
+
// Strict parse: drop malformed/underspecified rows rather than
|
|
123
|
+
// backfilling runId=active / stage=tdd defaults, which used to
|
|
124
|
+
// silently glue foreign entries to the current run.
|
|
125
|
+
const entries = parseTddCycleLog(raw, { strict: true });
|
|
100
126
|
cycleLogHasRed = hasFailingTestForPath(entries, args.targetPath, {
|
|
101
127
|
runId: effectiveRunId
|
|
102
128
|
});
|
|
@@ -119,7 +145,7 @@ export async function runTddRedEvidenceCommand(projectRoot, tokens, io) {
|
|
|
119
145
|
io.stdout.write(`${JSON.stringify({
|
|
120
146
|
ok: hasRed,
|
|
121
147
|
path: args.targetPath,
|
|
122
|
-
runId: effectiveRunId
|
|
148
|
+
runId: effectiveRunId,
|
|
123
149
|
sources: {
|
|
124
150
|
tddCycleLog: cycleLogHasRed,
|
|
125
151
|
autoEvidence: autoEvidenceHasRed
|
package/dist/tdd-cycle.d.ts
CHANGED
|
@@ -22,7 +22,28 @@ export interface TddCycleValidation {
|
|
|
22
22
|
openRedSlices: string[];
|
|
23
23
|
sliceCount: number;
|
|
24
24
|
}
|
|
25
|
-
export
|
|
25
|
+
export interface TddCycleParseIssue {
|
|
26
|
+
lineNumber: number;
|
|
27
|
+
reason: string;
|
|
28
|
+
rawLine: string;
|
|
29
|
+
}
|
|
30
|
+
export interface ParseTddCycleLogOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Collect one issue per dropped/malformed line. Callers that care
|
|
33
|
+
* (doctor, red-evidence) can surface them; hooks keep soft-fail.
|
|
34
|
+
*/
|
|
35
|
+
issues?: TddCycleParseIssue[];
|
|
36
|
+
/**
|
|
37
|
+
* When true, reject lines that omit required fields instead of
|
|
38
|
+
* back-filling them with defaults. Used by validation paths
|
|
39
|
+
* (`validateTddCycleOrder`, `cclaw doctor`) to avoid silently
|
|
40
|
+
* bucketing unscoped rows into "runId=active, stage=tdd". Soft paths
|
|
41
|
+
* (generated hooks) keep the legacy defaults so a half-written file
|
|
42
|
+
* never takes the session down.
|
|
43
|
+
*/
|
|
44
|
+
strict?: boolean;
|
|
45
|
+
}
|
|
46
|
+
export declare function parseTddCycleLog(text: string, options?: ParseTddCycleLogOptions): TddCycleEntry[];
|
|
26
47
|
export declare function validateTddCycleOrder(entries: TddCycleEntry[], options?: {
|
|
27
48
|
runId?: string;
|
|
28
49
|
}): TddCycleValidation;
|
package/dist/tdd-cycle.js
CHANGED
|
@@ -1,41 +1,77 @@
|
|
|
1
|
-
export function parseTddCycleLog(text) {
|
|
1
|
+
export function parseTddCycleLog(text, options = {}) {
|
|
2
|
+
const issues = options.issues;
|
|
3
|
+
const strict = options.strict === true;
|
|
2
4
|
const out = [];
|
|
3
5
|
// Strip a leading UTF-8 BOM on the whole blob so the first line parses
|
|
4
6
|
// cleanly; `trim()` handles BOM on subsequent lines through the same
|
|
5
7
|
// codepath (empty/whitespace-only lines are skipped).
|
|
6
8
|
const normalized = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
7
|
-
|
|
9
|
+
const lines = normalized.split(/\r?\n/);
|
|
10
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
11
|
+
const raw = lines[index] ?? "";
|
|
8
12
|
const line = raw.trim();
|
|
9
13
|
if (!line)
|
|
10
14
|
continue;
|
|
15
|
+
const lineNumber = index + 1;
|
|
16
|
+
let parsed;
|
|
11
17
|
try {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
parsed = JSON.parse(line);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
issues?.push({
|
|
22
|
+
lineNumber,
|
|
23
|
+
reason: `json-parse-failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
24
|
+
rawLine: raw
|
|
25
|
+
});
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const phase = parsed.phase;
|
|
29
|
+
if (phase !== "red" && phase !== "green" && phase !== "refactor") {
|
|
30
|
+
issues?.push({
|
|
31
|
+
lineNumber,
|
|
32
|
+
reason: `invalid-phase: ${JSON.stringify(parsed.phase)}`,
|
|
33
|
+
rawLine: raw
|
|
34
|
+
});
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const runIdField = typeof parsed.runId === "string" ? parsed.runId : null;
|
|
38
|
+
const stageField = typeof parsed.stage === "string" ? parsed.stage : null;
|
|
39
|
+
const sliceField = typeof parsed.slice === "string" ? parsed.slice : null;
|
|
40
|
+
if (strict) {
|
|
41
|
+
const missing = [];
|
|
42
|
+
if (!runIdField)
|
|
43
|
+
missing.push("runId");
|
|
44
|
+
if (!stageField)
|
|
45
|
+
missing.push("stage");
|
|
46
|
+
if (!sliceField)
|
|
47
|
+
missing.push("slice");
|
|
48
|
+
if (missing.length > 0) {
|
|
49
|
+
issues?.push({
|
|
50
|
+
lineNumber,
|
|
51
|
+
reason: `missing-required-fields: ${missing.join(",")}`,
|
|
52
|
+
rawLine: raw
|
|
53
|
+
});
|
|
15
54
|
continue;
|
|
16
55
|
}
|
|
17
|
-
const entry = {
|
|
18
|
-
ts: typeof parsed.ts === "string" ? parsed.ts : "",
|
|
19
|
-
runId: typeof parsed.runId === "string" ? parsed.runId : "active",
|
|
20
|
-
stage: typeof parsed.stage === "string" ? parsed.stage : "tdd",
|
|
21
|
-
slice: typeof parsed.slice === "string" ? parsed.slice : "S-unknown",
|
|
22
|
-
phase,
|
|
23
|
-
command: typeof parsed.command === "string" ? parsed.command : "",
|
|
24
|
-
files: Array.isArray(parsed.files)
|
|
25
|
-
? parsed.files.filter((item) => typeof item === "string")
|
|
26
|
-
: undefined,
|
|
27
|
-
exitCode: typeof parsed.exitCode === "number" ? parsed.exitCode : undefined,
|
|
28
|
-
note: typeof parsed.note === "string" ? parsed.note : undefined,
|
|
29
|
-
acIds: Array.isArray(parsed.acIds)
|
|
30
|
-
? parsed.acIds
|
|
31
|
-
.filter((item) => typeof item === "string" && item.length > 0)
|
|
32
|
-
: undefined
|
|
33
|
-
};
|
|
34
|
-
out.push(entry);
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
// skip malformed line
|
|
38
56
|
}
|
|
57
|
+
const entry = {
|
|
58
|
+
ts: typeof parsed.ts === "string" ? parsed.ts : "",
|
|
59
|
+
runId: runIdField ?? "active",
|
|
60
|
+
stage: stageField ?? "tdd",
|
|
61
|
+
slice: sliceField ?? "S-unknown",
|
|
62
|
+
phase,
|
|
63
|
+
command: typeof parsed.command === "string" ? parsed.command : "",
|
|
64
|
+
files: Array.isArray(parsed.files)
|
|
65
|
+
? parsed.files.filter((item) => typeof item === "string")
|
|
66
|
+
: undefined,
|
|
67
|
+
exitCode: typeof parsed.exitCode === "number" ? parsed.exitCode : undefined,
|
|
68
|
+
note: typeof parsed.note === "string" ? parsed.note : undefined,
|
|
69
|
+
acIds: Array.isArray(parsed.acIds)
|
|
70
|
+
? parsed.acIds
|
|
71
|
+
.filter((item) => typeof item === "string" && item.length > 0)
|
|
72
|
+
: undefined
|
|
73
|
+
};
|
|
74
|
+
out.push(entry);
|
|
39
75
|
}
|
|
40
76
|
return out;
|
|
41
77
|
}
|