jules-orchestrator-kit 0.38.2 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent/prompts/Bolt.md +10 -7
- package/.agent/prompts/Janitor.md +6 -6
- package/.agent/prompts/Task_Template.md +6 -5
- package/.agent/rules/jules-protocol.md +1 -1
- package/JULES_RULES_TEMPLATE.md +12 -8
- package/README.md +45 -8
- package/bin/agentctl.mjs +297 -9
- package/index.mjs +15 -1
- package/package.json +1 -1
- package/scripts/ci-scope-guard.mjs +186 -0
- package/scripts/jules-merge-swarm.mjs +13 -3
- package/src/assertions.mjs +579 -0
- package/src/budget.mjs +48 -6
- package/src/config.mjs +69 -6
- package/src/dag-engine.mjs +22 -1
- package/src/engine.mjs +133 -23
- package/src/evidence.mjs +111 -2
- package/src/execution-envelope.mjs +23 -7
- package/src/flaky-ledger.mjs +4 -1
- package/src/ops/command-registry.mjs +32 -0
- package/src/ops/doctor-planner.mjs +9 -3
- package/src/ops/pr-harvest.mjs +349 -0
- package/src/provider.mjs +491 -25
- package/src/review-repair.mjs +85 -7
- package/src/risk.mjs +134 -26
- package/src/role-resolver.mjs +54 -2
- package/src/router.mjs +64 -6
- package/src/state.mjs +15 -3
- package/src/task-optimizer.mjs +9 -1
- package/src/telemetry.mjs +65 -0
- package/src/web-templates.mjs +372 -7
- package/src/wizard-init.mjs +13 -3
- package/src/wizard-task.mjs +76 -6
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { readFileSync, statSync, readdirSync, existsSync } from "node:fs";
|
|
2
|
+
import { resolve, relative, join, sep } from "node:path";
|
|
3
|
+
import { gzipSync } from "node:zlib";
|
|
4
|
+
import { checkAssetIntegrity } from "./asset-integrity.mjs";
|
|
5
|
+
import { checkRulesBudget } from "./rules-budget.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalizes path to POSIX slashes.
|
|
9
|
+
* @param {string} p
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
export function normalizePosix(p = "") {
|
|
13
|
+
return p.split(sep).join("/").replace(/\\/g, "/");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Formats byte count into human-readable string.
|
|
18
|
+
* @param {number} bytes
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function formatBytes(bytes = 0) {
|
|
22
|
+
if (!Number.isFinite(bytes) || bytes === 0) return "0 B";
|
|
23
|
+
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
24
|
+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
25
|
+
const val = (bytes / Math.pow(1024, i)).toFixed(2);
|
|
26
|
+
return `${val.replace(/\.00$/, "")} ${units[i]}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolves byte limit from config options (maxBytes, maxKb, maxMb, maxGb).
|
|
31
|
+
* @param {object} opts
|
|
32
|
+
* @returns {number | null}
|
|
33
|
+
*/
|
|
34
|
+
export function resolveBytesLimit(opts = {}) {
|
|
35
|
+
if (typeof opts.maxBytes === "number" && Number.isFinite(opts.maxBytes)) {
|
|
36
|
+
return opts.maxBytes;
|
|
37
|
+
}
|
|
38
|
+
if (typeof opts.maxKb === "number" && Number.isFinite(opts.maxKb)) {
|
|
39
|
+
return opts.maxKb * 1024;
|
|
40
|
+
}
|
|
41
|
+
if (typeof opts.maxMb === "number" && Number.isFinite(opts.maxMb)) {
|
|
42
|
+
return opts.maxMb * 1024 * 1024;
|
|
43
|
+
}
|
|
44
|
+
if (typeof opts.maxGb === "number" && Number.isFinite(opts.maxGb)) {
|
|
45
|
+
return opts.maxGb * 1024 * 1024 * 1024;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Simple zero-dependency glob/pattern matcher supporting * and **.
|
|
52
|
+
* @param {string} pathStr
|
|
53
|
+
* @param {string} pattern
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
export function matchesGlob(pathStr, pattern) {
|
|
57
|
+
if (!pattern || pattern === "*") return true;
|
|
58
|
+
const normalizedPath = normalizePosix(pathStr).replace(/^\.\//, "");
|
|
59
|
+
const normalizedPattern = normalizePosix(pattern).replace(/^\.\//, "");
|
|
60
|
+
|
|
61
|
+
if (normalizedPath === normalizedPattern) return true;
|
|
62
|
+
|
|
63
|
+
// Convert simple glob pattern to RegExp
|
|
64
|
+
let regexStr = "^";
|
|
65
|
+
let i = 0;
|
|
66
|
+
while (i < normalizedPattern.length) {
|
|
67
|
+
const c = normalizedPattern[i];
|
|
68
|
+
if (c === "*" && normalizedPattern[i + 1] === "*") {
|
|
69
|
+
if (normalizedPattern[i + 2] === "/") {
|
|
70
|
+
regexStr += "(?:.*/)?";
|
|
71
|
+
i += 3;
|
|
72
|
+
continue;
|
|
73
|
+
} else {
|
|
74
|
+
regexStr += ".*";
|
|
75
|
+
i += 2;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
} else if (c === "*") {
|
|
79
|
+
regexStr += "[^/]*";
|
|
80
|
+
i++;
|
|
81
|
+
} else if (c === "?") {
|
|
82
|
+
regexStr += "[^/]";
|
|
83
|
+
i++;
|
|
84
|
+
} else if (["\\", ".", "+", "^", "$", "{", "}", "(", ")", "|", "[", "]"].includes(c)) {
|
|
85
|
+
regexStr += "\\" + c;
|
|
86
|
+
i++;
|
|
87
|
+
} else {
|
|
88
|
+
regexStr += c;
|
|
89
|
+
i++;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
regexStr += "$";
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const re = new RegExp(regexStr);
|
|
96
|
+
return re.test(normalizedPath);
|
|
97
|
+
} catch (_) {
|
|
98
|
+
return normalizedPath.includes(normalizedPattern);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Asserts total byte size of a directory.
|
|
104
|
+
*/
|
|
105
|
+
export function assertDirSize(config = {}, root = process.cwd()) {
|
|
106
|
+
const targetRel = config.path || config.dir || config.target || ".";
|
|
107
|
+
const targetAbs = resolve(root, targetRel);
|
|
108
|
+
const limitBytes = resolveBytesLimit(config);
|
|
109
|
+
const useGzip = Boolean(config.gzip);
|
|
110
|
+
|
|
111
|
+
if (!existsSync(targetAbs)) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
measuredBytes: 0,
|
|
115
|
+
limitBytes,
|
|
116
|
+
exceededBy: 0,
|
|
117
|
+
fileCount: 0,
|
|
118
|
+
gzip: useGzip,
|
|
119
|
+
diagnostics: [`Target directory does not exist: '${targetRel}'`],
|
|
120
|
+
metrics: { measuredBytes: 0, limitBytes, fileCount: 0, gzip: useGzip },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const stat = statSync(targetAbs);
|
|
125
|
+
if (!stat.isDirectory()) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
measuredBytes: 0,
|
|
129
|
+
limitBytes,
|
|
130
|
+
exceededBy: 0,
|
|
131
|
+
fileCount: 0,
|
|
132
|
+
gzip: useGzip,
|
|
133
|
+
diagnostics: [`Path '${targetRel}' is a file, not a directory. Use 'assert:file-size' instead.`],
|
|
134
|
+
metrics: { measuredBytes: 0, limitBytes, fileCount: 0, gzip: useGzip },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const includes = Array.isArray(config.include) ? config.include : config.include ? [config.include] : [];
|
|
139
|
+
const excludes = Array.isArray(config.exclude) ? config.exclude : config.exclude ? [config.exclude] : ["node_modules/**", ".git/**"];
|
|
140
|
+
|
|
141
|
+
let totalBytes = 0;
|
|
142
|
+
let fileCount = 0;
|
|
143
|
+
|
|
144
|
+
function scan(currentDir) {
|
|
145
|
+
let entries = [];
|
|
146
|
+
try {
|
|
147
|
+
entries = readdirSync(currentDir, { withFileTypes: true });
|
|
148
|
+
} catch (_) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
const fullPath = join(currentDir, entry.name);
|
|
154
|
+
const relToTarget = normalizePosix(relative(targetAbs, fullPath));
|
|
155
|
+
const relToRoot = normalizePosix(relative(root, fullPath));
|
|
156
|
+
|
|
157
|
+
if (entry.isDirectory()) {
|
|
158
|
+
const isExcluded = excludes.some((pat) => matchesGlob(relToTarget, pat) || matchesGlob(relToRoot, pat) || entry.name === "node_modules" || entry.name === ".git");
|
|
159
|
+
if (!isExcluded) {
|
|
160
|
+
scan(fullPath);
|
|
161
|
+
}
|
|
162
|
+
} else if (entry.isFile()) {
|
|
163
|
+
const isExcluded = excludes.some((pat) => matchesGlob(relToTarget, pat) || matchesGlob(relToRoot, pat));
|
|
164
|
+
if (isExcluded) continue;
|
|
165
|
+
|
|
166
|
+
if (includes.length > 0) {
|
|
167
|
+
const isIncluded = includes.some((pat) => matchesGlob(relToTarget, pat) || matchesGlob(relToRoot, pat));
|
|
168
|
+
if (!isIncluded) continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
if (useGzip) {
|
|
173
|
+
const buf = readFileSync(fullPath);
|
|
174
|
+
const gzipped = gzipSync(buf);
|
|
175
|
+
totalBytes += gzipped.length;
|
|
176
|
+
} else {
|
|
177
|
+
const fileStat = statSync(fullPath);
|
|
178
|
+
totalBytes += fileStat.size;
|
|
179
|
+
}
|
|
180
|
+
fileCount++;
|
|
181
|
+
} catch (_) {}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
scan(targetAbs);
|
|
187
|
+
|
|
188
|
+
const exceededBy = limitBytes !== null ? Math.max(0, totalBytes - limitBytes) : 0;
|
|
189
|
+
const ok = limitBytes !== null ? totalBytes <= limitBytes : true;
|
|
190
|
+
const diagnostics = [];
|
|
191
|
+
|
|
192
|
+
if (!ok) {
|
|
193
|
+
const formattedMeasured = formatBytes(totalBytes);
|
|
194
|
+
const formattedLimit = formatBytes(limitBytes);
|
|
195
|
+
const formattedExceeded = formatBytes(exceededBy);
|
|
196
|
+
diagnostics.push(
|
|
197
|
+
`Directory '${targetRel}' (${formattedMeasured}${useGzip ? " gzipped" : ""}) exceeds limit of ${formattedLimit} by ${formattedExceeded}.`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
ok,
|
|
203
|
+
measuredBytes: totalBytes,
|
|
204
|
+
limitBytes,
|
|
205
|
+
exceededBy,
|
|
206
|
+
fileCount,
|
|
207
|
+
gzip: useGzip,
|
|
208
|
+
diagnostics,
|
|
209
|
+
metrics: {
|
|
210
|
+
measuredBytes: totalBytes,
|
|
211
|
+
limitBytes,
|
|
212
|
+
exceededBy,
|
|
213
|
+
fileCount,
|
|
214
|
+
gzip: useGzip,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Asserts size of a single file.
|
|
221
|
+
*/
|
|
222
|
+
export function assertFileSize(config = {}, root = process.cwd()) {
|
|
223
|
+
const targetRel = config.path || config.file || config.target;
|
|
224
|
+
if (!targetRel) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
measuredBytes: 0,
|
|
228
|
+
limitBytes: null,
|
|
229
|
+
exceededBy: 0,
|
|
230
|
+
gzip: false,
|
|
231
|
+
diagnostics: ["'assert:file-size' requires a 'path' or 'file' parameter."],
|
|
232
|
+
metrics: { measuredBytes: 0, limitBytes: null },
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const targetAbs = resolve(root, targetRel);
|
|
237
|
+
const limitBytes = resolveBytesLimit(config);
|
|
238
|
+
const useGzip = Boolean(config.gzip);
|
|
239
|
+
|
|
240
|
+
if (!existsSync(targetAbs)) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
measuredBytes: 0,
|
|
244
|
+
limitBytes,
|
|
245
|
+
exceededBy: 0,
|
|
246
|
+
gzip: useGzip,
|
|
247
|
+
diagnostics: [`Target file does not exist: '${targetRel}'`],
|
|
248
|
+
metrics: { measuredBytes: 0, limitBytes, gzip: useGzip },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let measuredBytes = 0;
|
|
253
|
+
try {
|
|
254
|
+
if (useGzip) {
|
|
255
|
+
const buf = readFileSync(targetAbs);
|
|
256
|
+
measuredBytes = gzipSync(buf).length;
|
|
257
|
+
} else {
|
|
258
|
+
measuredBytes = statSync(targetAbs).size;
|
|
259
|
+
}
|
|
260
|
+
} catch (err) {
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
measuredBytes: 0,
|
|
264
|
+
limitBytes,
|
|
265
|
+
exceededBy: 0,
|
|
266
|
+
gzip: useGzip,
|
|
267
|
+
diagnostics: [`Failed to read file '${targetRel}': ${err.message}`],
|
|
268
|
+
metrics: { measuredBytes: 0, limitBytes, gzip: useGzip },
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const exceededBy = limitBytes !== null ? Math.max(0, measuredBytes - limitBytes) : 0;
|
|
273
|
+
const ok = limitBytes !== null ? measuredBytes <= limitBytes : true;
|
|
274
|
+
const diagnostics = [];
|
|
275
|
+
|
|
276
|
+
if (!ok) {
|
|
277
|
+
const formattedMeasured = formatBytes(measuredBytes);
|
|
278
|
+
const formattedLimit = formatBytes(limitBytes);
|
|
279
|
+
const formattedExceeded = formatBytes(exceededBy);
|
|
280
|
+
diagnostics.push(
|
|
281
|
+
`File '${targetRel}' (${formattedMeasured}${useGzip ? " gzipped" : ""}) exceeds limit of ${formattedLimit} by ${formattedExceeded}.`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
ok,
|
|
287
|
+
measuredBytes,
|
|
288
|
+
limitBytes,
|
|
289
|
+
exceededBy,
|
|
290
|
+
gzip: useGzip,
|
|
291
|
+
diagnostics,
|
|
292
|
+
metrics: {
|
|
293
|
+
measuredBytes,
|
|
294
|
+
limitBytes,
|
|
295
|
+
exceededBy,
|
|
296
|
+
gzip: useGzip,
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Asserts that files matching targets contain no forbidden patterns or banned terms.
|
|
303
|
+
*/
|
|
304
|
+
export function assertFilePatterns(config = {}, root = process.cwd()) {
|
|
305
|
+
const targets = Array.isArray(config.targets)
|
|
306
|
+
? config.targets
|
|
307
|
+
: Array.isArray(config.files)
|
|
308
|
+
? config.files
|
|
309
|
+
: config.targets
|
|
310
|
+
? [config.targets]
|
|
311
|
+
: config.files
|
|
312
|
+
? [config.files]
|
|
313
|
+
: config.path
|
|
314
|
+
? [config.path]
|
|
315
|
+
: ["src/**/*"];
|
|
316
|
+
|
|
317
|
+
let patterns = [];
|
|
318
|
+
if (Array.isArray(config.patterns)) {
|
|
319
|
+
patterns.push(...config.patterns);
|
|
320
|
+
}
|
|
321
|
+
if (Array.isArray(config.forbiddenRegex)) {
|
|
322
|
+
patterns.push(...config.forbiddenRegex);
|
|
323
|
+
}
|
|
324
|
+
if (Array.isArray(config.bannedWords)) {
|
|
325
|
+
patterns.push(...config.bannedWords);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (config.patternsFile) {
|
|
329
|
+
const patFileAbs = resolve(root, config.patternsFile);
|
|
330
|
+
if (existsSync(patFileAbs)) {
|
|
331
|
+
try {
|
|
332
|
+
const raw = readFileSync(patFileAbs, "utf-8");
|
|
333
|
+
const parsed = JSON.parse(raw);
|
|
334
|
+
if (Array.isArray(parsed)) {
|
|
335
|
+
patterns.push(...parsed);
|
|
336
|
+
} else if (parsed && Array.isArray(parsed.patterns)) {
|
|
337
|
+
patterns.push(...parsed.patterns);
|
|
338
|
+
}
|
|
339
|
+
} catch (err) {
|
|
340
|
+
return {
|
|
341
|
+
ok: false,
|
|
342
|
+
matchCount: 0,
|
|
343
|
+
matches: [],
|
|
344
|
+
diagnostics: [`Failed to parse patternsFile '${config.patternsFile}': ${err.message}`],
|
|
345
|
+
metrics: { filesScanned: 0, matchCount: 0 },
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
} else {
|
|
349
|
+
return {
|
|
350
|
+
ok: false,
|
|
351
|
+
matchCount: 0,
|
|
352
|
+
matches: [],
|
|
353
|
+
diagnostics: [`patternsFile not found: '${config.patternsFile}'`],
|
|
354
|
+
metrics: { filesScanned: 0, matchCount: 0 },
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (patterns.length === 0) {
|
|
360
|
+
return {
|
|
361
|
+
ok: true,
|
|
362
|
+
matchCount: 0,
|
|
363
|
+
matches: [],
|
|
364
|
+
diagnostics: [],
|
|
365
|
+
metrics: { filesScanned: 0, matchCount: 0 },
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const compiledPatterns = patterns.map((p) => {
|
|
370
|
+
if (p instanceof RegExp) return { raw: p.source, re: p };
|
|
371
|
+
const str = String(p);
|
|
372
|
+
try {
|
|
373
|
+
return { raw: str, re: new RegExp(str, "g") };
|
|
374
|
+
} catch (_) {
|
|
375
|
+
const escaped = str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
376
|
+
return { raw: str, re: new RegExp(escaped, "g") };
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const candidateFiles = [];
|
|
381
|
+
|
|
382
|
+
function collectFiles(dir) {
|
|
383
|
+
let entries = [];
|
|
384
|
+
try {
|
|
385
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
386
|
+
} catch (_) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
for (const entry of entries) {
|
|
391
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
392
|
+
const fullPath = join(dir, entry.name);
|
|
393
|
+
const relPath = normalizePosix(relative(root, fullPath));
|
|
394
|
+
|
|
395
|
+
if (entry.isDirectory()) {
|
|
396
|
+
collectFiles(fullPath);
|
|
397
|
+
} else if (entry.isFile()) {
|
|
398
|
+
const matchesAnyTarget = targets.some((tgt) => matchesGlob(relPath, tgt));
|
|
399
|
+
if (matchesAnyTarget) {
|
|
400
|
+
candidateFiles.push(relPath);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
collectFiles(root);
|
|
407
|
+
|
|
408
|
+
const matches = [];
|
|
409
|
+
|
|
410
|
+
for (const relFile of candidateFiles) {
|
|
411
|
+
const absFile = resolve(root, relFile);
|
|
412
|
+
let content = "";
|
|
413
|
+
try {
|
|
414
|
+
content = readFileSync(absFile, "utf-8");
|
|
415
|
+
} catch (_) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const lines = content.split("\n");
|
|
420
|
+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
421
|
+
const line = lines[lineIdx];
|
|
422
|
+
for (const pat of compiledPatterns) {
|
|
423
|
+
pat.re.lastIndex = 0;
|
|
424
|
+
if (pat.re.test(line)) {
|
|
425
|
+
matches.push({
|
|
426
|
+
file: relFile,
|
|
427
|
+
line: lineIdx + 1,
|
|
428
|
+
pattern: pat.raw,
|
|
429
|
+
snippet: line.trim().slice(0, 120),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const ok = matches.length === 0;
|
|
437
|
+
const diagnostics = matches.map(
|
|
438
|
+
(m) => `Forbidden pattern '${m.pattern}' found in ${m.file}:${m.line}: "${m.snippet}"`
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
ok,
|
|
443
|
+
matchCount: matches.length,
|
|
444
|
+
matches,
|
|
445
|
+
diagnostics,
|
|
446
|
+
metrics: {
|
|
447
|
+
filesScanned: candidateFiles.length,
|
|
448
|
+
matchCount: matches.length,
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Asserts file existence or absence.
|
|
455
|
+
*/
|
|
456
|
+
export function assertFileExists(config = {}, root = process.cwd()) {
|
|
457
|
+
const targetRel = config.path || config.file || config.target;
|
|
458
|
+
const mustExist = config.mustExist !== false && config.exists !== false;
|
|
459
|
+
|
|
460
|
+
if (!targetRel) {
|
|
461
|
+
return {
|
|
462
|
+
ok: false,
|
|
463
|
+
exists: false,
|
|
464
|
+
diagnostics: ["'assert:exists' requires a 'path' parameter."],
|
|
465
|
+
metrics: {},
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const targetAbs = resolve(root, targetRel);
|
|
470
|
+
const exists = existsSync(targetAbs);
|
|
471
|
+
const ok = mustExist ? exists : !exists;
|
|
472
|
+
const diagnostics = [];
|
|
473
|
+
|
|
474
|
+
if (!ok) {
|
|
475
|
+
diagnostics.push(
|
|
476
|
+
mustExist
|
|
477
|
+
? `Required file or directory '${targetRel}' does not exist.`
|
|
478
|
+
: `Path '${targetRel}' exists but was asserted to not exist.`
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
ok,
|
|
484
|
+
exists,
|
|
485
|
+
diagnostics,
|
|
486
|
+
metrics: { path: targetRel, exists, mustExist },
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Dispatches and executes an assertion primitive.
|
|
492
|
+
*/
|
|
493
|
+
export function runAssertion(stage = {}, root = process.cwd()) {
|
|
494
|
+
const startTime = Date.now();
|
|
495
|
+
const rawType = stage.assert || stage.type || stage.kind || "";
|
|
496
|
+
const assertionType = String(rawType).toLowerCase().replace(/^assert:/, "").trim();
|
|
497
|
+
|
|
498
|
+
let res;
|
|
499
|
+
switch (assertionType) {
|
|
500
|
+
case "dir-size":
|
|
501
|
+
case "dir_size":
|
|
502
|
+
case "directory-size":
|
|
503
|
+
res = assertDirSize(stage, root);
|
|
504
|
+
break;
|
|
505
|
+
|
|
506
|
+
case "file-size":
|
|
507
|
+
case "file_size":
|
|
508
|
+
res = assertFileSize(stage, root);
|
|
509
|
+
break;
|
|
510
|
+
|
|
511
|
+
case "file-patterns":
|
|
512
|
+
case "file_patterns":
|
|
513
|
+
case "patterns":
|
|
514
|
+
case "pattern":
|
|
515
|
+
case "no-forbidden-terms":
|
|
516
|
+
case "forbidden-patterns":
|
|
517
|
+
res = assertFilePatterns(stage, root);
|
|
518
|
+
break;
|
|
519
|
+
|
|
520
|
+
case "asset-integrity":
|
|
521
|
+
case "asset_integrity":
|
|
522
|
+
case "assets": {
|
|
523
|
+
const targetDir = stage.path || stage.dir || stage.searchDir || "public";
|
|
524
|
+
const checkRes = checkAssetIntegrity(resolve(root, targetDir));
|
|
525
|
+
const diag = (checkRes.corruptedFiles || []).map((f) => `Corrupted asset: ${f.path} (${f.reason})`);
|
|
526
|
+
res = {
|
|
527
|
+
ok: checkRes.ok,
|
|
528
|
+
diagnostics: diag,
|
|
529
|
+
metrics: { checkedCount: checkRes.checkedCount, corruptedCount: checkRes.corruptedFiles?.length || 0 },
|
|
530
|
+
};
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
case "rules-budget":
|
|
535
|
+
case "rules_budget": {
|
|
536
|
+
const budgetRes = checkRulesBudget(root, stage);
|
|
537
|
+
const diag = (budgetRes.violations || []).map((v) => `${v.path}: ${v.reason}`);
|
|
538
|
+
res = {
|
|
539
|
+
ok: budgetRes.ok,
|
|
540
|
+
diagnostics: diag,
|
|
541
|
+
metrics: { violationCount: budgetRes.violations?.length || 0 },
|
|
542
|
+
};
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
case "exists":
|
|
547
|
+
case "file-exists":
|
|
548
|
+
case "not-exists": {
|
|
549
|
+
const cfg = { ...stage, mustExist: assertionType !== "not-exists" };
|
|
550
|
+
res = assertFileExists(cfg, root);
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
default:
|
|
555
|
+
res = {
|
|
556
|
+
ok: false,
|
|
557
|
+
diagnostics: [`Unknown assertion primitive type: '${rawType}'`],
|
|
558
|
+
metrics: {},
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const durationMs = Date.now() - startTime;
|
|
563
|
+
const metrics = { ...(res.metrics || {}), durationMs };
|
|
564
|
+
const ok = Boolean(res.ok);
|
|
565
|
+
const status = ok ? 0 : 1;
|
|
566
|
+
|
|
567
|
+
const stdout = ok ? `Assertion '${assertionType}' PASSED (${durationMs}ms)` : "";
|
|
568
|
+
const stderr = !ok ? (res.diagnostics || []).join("\n") || `Assertion '${assertionType}' FAILED` : "";
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
ok,
|
|
572
|
+
status,
|
|
573
|
+
assertionType,
|
|
574
|
+
stdout,
|
|
575
|
+
stderr,
|
|
576
|
+
diagnostics: res.diagnostics || [],
|
|
577
|
+
metrics,
|
|
578
|
+
};
|
|
579
|
+
}
|
package/src/budget.mjs
CHANGED
|
@@ -1,15 +1,56 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, openSync, fsyncSync, closeSync, renameSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { userInfo } from "node:os";
|
|
3
5
|
import { resolveRoot } from "./config.mjs";
|
|
4
6
|
import {
|
|
5
7
|
getStateDir,
|
|
6
8
|
ensureDir,
|
|
7
9
|
appendLedger,
|
|
8
|
-
checkDailyBudget,
|
|
9
10
|
scanBudgetWindow,
|
|
10
11
|
ROLLING_WINDOW_MS,
|
|
11
12
|
} from "./state.mjs";
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Resolves ambient developer identity with zero dependencies and strict PII protection.
|
|
16
|
+
* Hierarchy: CLI flag -> GITHUB_ACTOR -> Git config user.email (stripped of domain) -> OS username -> anonymous-local
|
|
17
|
+
* @param {string|null} [cliOverride]
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
export function resolveAmbientIdentity(cliOverride = null) {
|
|
21
|
+
const sanitize = (str) =>
|
|
22
|
+
(str || "")
|
|
23
|
+
.toString()
|
|
24
|
+
.trim()
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.split("@")[0] // Strip email domain to prevent PII leakage
|
|
27
|
+
.replace(/[^a-z0-9_.-]/g, ""); // Remove unsafe injection/special chars
|
|
28
|
+
|
|
29
|
+
if (cliOverride) {
|
|
30
|
+
const cleaned = sanitize(cliOverride);
|
|
31
|
+
if (cleaned) return cleaned;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (process.env.GITHUB_ACTOR) {
|
|
35
|
+
const actor = sanitize(process.env.GITHUB_ACTOR);
|
|
36
|
+
if (actor) return `ci-${actor}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const gitEmail = execSync("git config user.email", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
41
|
+
const cleaned = sanitize(gitEmail);
|
|
42
|
+
if (cleaned) return cleaned;
|
|
43
|
+
} catch (_) {}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const osUser = process.env.USER || process.env.USERNAME || userInfo().username;
|
|
47
|
+
const cleaned = sanitize(osUser);
|
|
48
|
+
if (cleaned) return `${cleaned}-local`;
|
|
49
|
+
} catch (_) {}
|
|
50
|
+
|
|
51
|
+
return "anonymous-local";
|
|
52
|
+
}
|
|
53
|
+
|
|
13
54
|
/**
|
|
14
55
|
* Where the observed quota ceiling lives, outside the ledger so it survives
|
|
15
56
|
* rotation.
|
|
@@ -347,21 +388,22 @@ export function releaseOpenReservations(opts = {}) {
|
|
|
347
388
|
*/
|
|
348
389
|
export function budgetStatus(config, root = resolveRoot()) {
|
|
349
390
|
const resolved = resolveDailyLimit(config, root);
|
|
350
|
-
const
|
|
391
|
+
const scan = scanBudgetWindow(root);
|
|
351
392
|
return {
|
|
352
|
-
used:
|
|
393
|
+
used: scan.used,
|
|
353
394
|
limit: resolved.limit,
|
|
354
|
-
remaining:
|
|
395
|
+
remaining: Math.max(0, resolved.limit - scan.used),
|
|
396
|
+
byUser: scan.byUser || {},
|
|
355
397
|
tier: config?.tier || "unknown",
|
|
356
398
|
source: resolved.source,
|
|
357
399
|
certain: resolved.certain,
|
|
358
400
|
note: resolved.note,
|
|
359
401
|
// The count is a rolling 24h window, not a calendar day — surfaced so a
|
|
360
402
|
// caller reporting "used today" cannot quietly mean something else.
|
|
361
|
-
windowStart:
|
|
403
|
+
windowStart: scan.windowStart || "",
|
|
362
404
|
windowHours: ROLLING_WINDOW_MS / 3600000,
|
|
363
405
|
// Only a limit we actually know may stop a dispatch.
|
|
364
406
|
enforced: resolved.certain,
|
|
365
|
-
exhausted:
|
|
407
|
+
exhausted: scan.used >= resolved.limit,
|
|
366
408
|
};
|
|
367
409
|
}
|