jules-orchestrator-kit 0.70.0 → 0.72.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/rules/jules-protocol.md +0 -1
- package/CHANGELOG.md +66 -0
- package/JULES_RULES_TEMPLATE.md +0 -1
- package/README.md +56 -1
- package/ROADMAP_V1.md +31 -6
- package/bin/agentctl.mjs +101 -8
- package/package.json +1 -1
- package/scripts/guard-reach-check.mjs +74 -8
- package/src/config.mjs +434 -5
- package/src/coverage.mjs +85 -13
- package/src/engine.mjs +106 -130
- package/src/git.mjs +104 -1
- package/src/guard-policy.mjs +581 -0
- package/src/ops/command-registry.mjs +109 -0
- package/src/ops/test-collection.mjs +103 -3
- package/src/security.mjs +554 -4
- package/src/stack-detector.mjs +73 -14
- package/src/test-paths.mjs +18 -1
- package/src/wizard-init.mjs +54 -27
- package/src/wizard-oracle.mjs +1 -1
- package/src/wizard-task.mjs +15 -1
package/src/config.mjs
CHANGED
|
@@ -205,7 +205,13 @@ const BLOCKED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
|
205
205
|
|
|
206
206
|
function coerce(val) {
|
|
207
207
|
if (!val) return "";
|
|
208
|
-
if (
|
|
208
|
+
if (val.startsWith("'") && val.endsWith("'") && val.length >= 2) {
|
|
209
|
+
// `''` is how a single-quoted YAML scalar escapes a quote, and it is what
|
|
210
|
+
// `yamlScalar` emits. Reading the quotes off without undoing the escape
|
|
211
|
+
// meant the emitter and the parser disagreed about the same file.
|
|
212
|
+
return val.slice(1, -1).replace(/''/g, "'");
|
|
213
|
+
}
|
|
214
|
+
if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
|
|
209
215
|
return val.slice(1, -1);
|
|
210
216
|
}
|
|
211
217
|
if (val === "true") return true;
|
|
@@ -315,6 +321,86 @@ export function normalizeScope(parsed = {}) {
|
|
|
315
321
|
};
|
|
316
322
|
}
|
|
317
323
|
|
|
324
|
+
/**
|
|
325
|
+
* A YAML scalar quoted only as much as YAML requires.
|
|
326
|
+
*
|
|
327
|
+
* The scaffold wrapped every generated string in double quotes, which is valid
|
|
328
|
+
* YAML and idiomatic nowhere. On a repository that lints its own YAML that is a
|
|
329
|
+
* hard red on the kit's own files: measured on `unjs/unimport`, following the
|
|
330
|
+
* README verbatim produced 40 eslint errors — `yaml/quotes` and
|
|
331
|
+
* `yaml/plain-scalar` — across `.agent/config.yml` and `.agent/jules.yml`, so a
|
|
332
|
+
* newcomer's very first `agentctl check` failed on output `agentctl init` had
|
|
333
|
+
* just written, in a repository that was green a minute earlier. Teaching a new
|
|
334
|
+
* user that the gate is broken is the most expensive thing this tool can do.
|
|
335
|
+
*
|
|
336
|
+
* Nothing here is specific to eslint or to that repository. Emitting a plain
|
|
337
|
+
* scalar where YAML permits one, and single quotes where it does not, is simply
|
|
338
|
+
* how YAML is written; the linters that check it are agreeing with the spec.
|
|
339
|
+
*
|
|
340
|
+
* The quoting test is deliberately conservative — anything that could change
|
|
341
|
+
* meaning unquoted gets quotes. A glob beginning with a star stays quoted,
|
|
342
|
+
* because a leading `*` is an alias reference; `pnpm -r test` does not, because
|
|
343
|
+
* there is nothing in it to misread.
|
|
344
|
+
*/
|
|
345
|
+
const YAML_NEEDS_QUOTES =
|
|
346
|
+
/^$|^[-?:,[\]{}#&*!|>'"%@`]|^\s|\s$|:\s|\s#|:$|[\n\r\t]|^(?:true|false|yes|no|on|off|null|~)$/i;
|
|
347
|
+
|
|
348
|
+
export function yamlScalar(value) {
|
|
349
|
+
const s = value === null || value === undefined ? "" : String(value);
|
|
350
|
+
if (!YAML_NEEDS_QUOTES.test(s) && !/^[+-]?(?:\d[\d_]*)(?:\.[\d_]*)?(?:[eE][+-]?\d+)?$/.test(s)) return s;
|
|
351
|
+
return `'${s.replace(/'/g, "''")}'`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Can a quoted scalar begin at this offset?
|
|
356
|
+
*
|
|
357
|
+
* A quote character is only an opening quote where a value may start — after
|
|
358
|
+
* `key:`, after a `-` list marker, at the head of a flow collection, or at the
|
|
359
|
+
* start of the line. Anywhere else it is an ordinary apostrophe, and treating
|
|
360
|
+
* `test: it's fine` as an unterminated string would swallow the rest of it.
|
|
361
|
+
*/
|
|
362
|
+
function opensQuotedScalar(line, i) {
|
|
363
|
+
return /(?:^\s*|:\s+|-\s+|[[{,]\s*)$/.test(line.slice(0, i));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Strip a trailing `#` comment without cutting into a quoted scalar.
|
|
368
|
+
*
|
|
369
|
+
* `rawLine.indexOf("#")` stood here, which is not the YAML rule twice over: a
|
|
370
|
+
* `#` opens a comment only when it begins the line or follows whitespace, and
|
|
371
|
+
* never inside quotes. So any configured value containing a hash was silently
|
|
372
|
+
* truncated on the way in — `test: 'pytest -k "not #slow"'` became
|
|
373
|
+
* `test: 'pytest -k "not`, and the gate then ran a command the user never
|
|
374
|
+
* wrote. Silent truncation of a verification command is the worst shape this
|
|
375
|
+
* file can produce, because every later phase reports on whatever did run.
|
|
376
|
+
*
|
|
377
|
+
* It matters more now that `yamlScalar` exists: an emitter and a parser that
|
|
378
|
+
* disagree about the same file is this project's recurring defect with both
|
|
379
|
+
* halves in one module.
|
|
380
|
+
*/
|
|
381
|
+
function stripYamlComment(rawLine) {
|
|
382
|
+
let quote = null;
|
|
383
|
+
for (let i = 0; i < rawLine.length; i++) {
|
|
384
|
+
const ch = rawLine[i];
|
|
385
|
+
if (quote) {
|
|
386
|
+
if (quote === '"' && ch === "\\") {
|
|
387
|
+
i++;
|
|
388
|
+
} else if (ch === quote) {
|
|
389
|
+
// `''` is an escaped quote inside a single-quoted scalar, not the end.
|
|
390
|
+
if (quote === "'" && rawLine[i + 1] === "'") i++;
|
|
391
|
+
else quote = null;
|
|
392
|
+
}
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if ((ch === '"' || ch === "'") && opensQuotedScalar(rawLine, i)) {
|
|
396
|
+
quote = ch;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (ch === "#" && (i === 0 || /\s/.test(rawLine[i - 1]))) return rawLine.slice(0, i);
|
|
400
|
+
}
|
|
401
|
+
return rawLine;
|
|
402
|
+
}
|
|
403
|
+
|
|
318
404
|
/**
|
|
319
405
|
* Indent-stack zero-dependency YAML parser with prototype pollution protection.
|
|
320
406
|
*/
|
|
@@ -331,8 +417,7 @@ export function parseYaml(src) {
|
|
|
331
417
|
const lines = src.split("\n");
|
|
332
418
|
|
|
333
419
|
for (let rawLine of lines) {
|
|
334
|
-
const
|
|
335
|
-
const line = commentIdx !== -1 ? rawLine.slice(0, commentIdx) : rawLine;
|
|
420
|
+
const line = stripYamlComment(rawLine);
|
|
336
421
|
const trimmed = line.trim();
|
|
337
422
|
if (!trimmed) continue;
|
|
338
423
|
|
|
@@ -433,9 +518,9 @@ export function detectPackageManager(root = process.cwd(), pkg = {}) {
|
|
|
433
518
|
return "npm";
|
|
434
519
|
}
|
|
435
520
|
|
|
436
|
-
import { buildProfileStages, PROFILE_NAMES, PROFILE_DESCRIPTIONS } from "./profiles.mjs";
|
|
521
|
+
import { buildProfileStages, buildDefaultStages, PROFILE_NAMES, PROFILE_DESCRIPTIONS } from "./profiles.mjs";
|
|
437
522
|
|
|
438
|
-
export { buildProfileStages, PROFILE_NAMES, PROFILE_DESCRIPTIONS };
|
|
523
|
+
export { buildProfileStages, buildDefaultStages, PROFILE_NAMES, PROFILE_DESCRIPTIONS };
|
|
439
524
|
|
|
440
525
|
import {
|
|
441
526
|
detectPolyglotStack,
|
|
@@ -444,6 +529,7 @@ import {
|
|
|
444
529
|
findSubprojectRoot,
|
|
445
530
|
detectCrossPackageBoundaryViolations,
|
|
446
531
|
detectCircularDependencies,
|
|
532
|
+
isPlaceholderTestScript,
|
|
447
533
|
} from "./stack-detector.mjs";
|
|
448
534
|
|
|
449
535
|
export {
|
|
@@ -453,8 +539,11 @@ export {
|
|
|
453
539
|
findSubprojectRoot,
|
|
454
540
|
detectCrossPackageBoundaryViolations,
|
|
455
541
|
detectCircularDependencies,
|
|
542
|
+
isPlaceholderTestScript,
|
|
456
543
|
};
|
|
457
544
|
|
|
545
|
+
import { showFromOrigin, detectDefaultBranch } from "./git.mjs";
|
|
546
|
+
|
|
458
547
|
/**
|
|
459
548
|
* Autodetects verification test/build commands across 24+ polyglot tech stacks.
|
|
460
549
|
*/
|
|
@@ -703,6 +792,7 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
|
|
|
703
792
|
// Named so `doctor` can tell an operator that the gate they enabled is
|
|
704
793
|
// running one fewer check than they think, and why.
|
|
705
794
|
profileSkipped: profilePlan?.skipped ?? [],
|
|
795
|
+
minTests: parsed.verify?.minTests ?? parsed.verify?.min_tests ?? (parsed.minTests ?? parsed.min_tests ?? 1),
|
|
706
796
|
timeoutMs: Number.isFinite(Number(verifyTimeoutMs)) ? Number(verifyTimeoutMs) : 300_000,
|
|
707
797
|
},
|
|
708
798
|
evidence: {
|
|
@@ -780,3 +870,342 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
|
|
|
780
870
|
|
|
781
871
|
return config;
|
|
782
872
|
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Verifies that a bootstrap scaffold (introduced in a repository whose base has
|
|
876
|
+
* no gate configuration yet) does not weaken verification, lower profiles,
|
|
877
|
+
* bypass tests, disable strict locks, or tamper with security invariants (F06, F08).
|
|
878
|
+
*
|
|
879
|
+
* @param {string} root - repository root
|
|
880
|
+
* @param {object} proposedConfig - parsed .agent/config.yml if present
|
|
881
|
+
* @param {object} proposedJules - parsed .agent/jules.yml if present
|
|
882
|
+
* @param {object} autoVerify - autodetected verification commands
|
|
883
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
884
|
+
*/
|
|
885
|
+
export function checkBootstrapPolicyIntegrity(root, proposedConfig = {}, proposedJules = {}, autoVerify = {}) {
|
|
886
|
+
const pConfig = proposedConfig || {};
|
|
887
|
+
const pJules = proposedJules || {};
|
|
888
|
+
|
|
889
|
+
const testCmd = pConfig.verify?.test ?? pConfig.test_cmd ?? pJules.test_cmd ?? pJules.verify?.test;
|
|
890
|
+
if (testCmd !== undefined && testCmd !== null) {
|
|
891
|
+
const str = String(testCmd).trim();
|
|
892
|
+
if (str && isPlaceholderTestScript(str)) {
|
|
893
|
+
return {
|
|
894
|
+
ok: false,
|
|
895
|
+
error: `Bootstrap policy rejected: verify.test cannot be a placeholder command (${JSON.stringify(str)}) in uncommitted scaffold (F06).`,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
if (!str && autoVerify.test) {
|
|
899
|
+
return {
|
|
900
|
+
ok: false,
|
|
901
|
+
error: `Bootstrap policy rejected: verify.test cannot be empty when repository has detected tests (${autoVerify.test}).`,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const profile = pConfig.verify?.profile ?? pConfig.profile ?? pJules.verify?.profile ?? pJules.profile;
|
|
907
|
+
if (profile && String(profile).toLowerCase() === "minimal") {
|
|
908
|
+
return {
|
|
909
|
+
ok: false,
|
|
910
|
+
error: 'Bootstrap policy rejected: verify.profile cannot be lowered to "minimal" in uncommitted scaffold (F06).',
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const required = pConfig.verify?.required ?? pJules.verify?.required;
|
|
915
|
+
if (required === false) {
|
|
916
|
+
return {
|
|
917
|
+
ok: false,
|
|
918
|
+
error: "Bootstrap policy rejected: verify.required cannot be set to false in uncommitted scaffold.",
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const strictLock =
|
|
923
|
+
pConfig.evidence?.strict_test_lock ??
|
|
924
|
+
pConfig.evidence?.strictTestLock ??
|
|
925
|
+
pJules.evidence?.strict_test_lock ??
|
|
926
|
+
pJules.evidence?.strictTestLock;
|
|
927
|
+
if (strictLock === false) {
|
|
928
|
+
return {
|
|
929
|
+
ok: false,
|
|
930
|
+
error: "Bootstrap policy rejected: evidence.strictTestLock cannot be disabled in uncommitted scaffold.",
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const tamperGuard =
|
|
935
|
+
pConfig.verify?.tamper_guard ??
|
|
936
|
+
pConfig.verify?.tamperGuard ??
|
|
937
|
+
pJules.verify?.tamper_guard ??
|
|
938
|
+
pJules.verify?.tamperGuard;
|
|
939
|
+
if (tamperGuard === false) {
|
|
940
|
+
return {
|
|
941
|
+
ok: false,
|
|
942
|
+
error: "Bootstrap policy rejected: verify.tamperGuard cannot be disabled in uncommitted scaffold.",
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const baseBranch = pConfig.base_branch ?? pConfig.baseBranch ?? pJules.base_branch ?? pJules.baseBranch;
|
|
947
|
+
if (baseBranch && String(baseBranch).toUpperCase() === "HEAD") {
|
|
948
|
+
return {
|
|
949
|
+
ok: false,
|
|
950
|
+
error: 'Bootstrap policy rejected: base_branch cannot be set to "HEAD" (F08).',
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const stages = pConfig.verify?.stages ?? pJules.verify?.stages;
|
|
955
|
+
if (Array.isArray(stages)) {
|
|
956
|
+
if (stages.length === 0) {
|
|
957
|
+
return {
|
|
958
|
+
ok: false,
|
|
959
|
+
error: "Bootstrap policy rejected: verify.stages cannot be empty in uncommitted scaffold.",
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
const hasTestStage = stages.some(
|
|
963
|
+
(s) => (s.kind === "test" || s.kind === "unit") && s.cmd && !isPlaceholderTestScript(s.cmd)
|
|
964
|
+
);
|
|
965
|
+
if (!hasTestStage && autoVerify.test) {
|
|
966
|
+
return {
|
|
967
|
+
ok: false,
|
|
968
|
+
error: "Bootstrap policy rejected: verify.stages must include a valid test/unit command in uncommitted scaffold.",
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
return { ok: true };
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Resolves the authoritative security policy and verification plan from the
|
|
978
|
+
* trusted base commit (or pristine bootstrap baseline), guaranteeing that
|
|
979
|
+
* untrusted edits in the working tree, staged index, or branch diff under
|
|
980
|
+
* review cannot weaken verification, disable security guards, or redirect
|
|
981
|
+
* the trusted base (F06, F07, F08).
|
|
982
|
+
*
|
|
983
|
+
* @param {string} root - repository root
|
|
984
|
+
* @param {string} [baseRef] - explicit base reference (e.g. from CLI --base)
|
|
985
|
+
* @param {string} [mode="working-tree"] - "working-tree" | "staged" | "committed"
|
|
986
|
+
* @param {object} [opts={}] - additional options (e.g. allowProtected, config)
|
|
987
|
+
* @returns {{
|
|
988
|
+
* ok: boolean,
|
|
989
|
+
* code?: number,
|
|
990
|
+
* error?: string,
|
|
991
|
+
* base: string,
|
|
992
|
+
* scope: { deny: string[], allow: string[], protect: string[] },
|
|
993
|
+
* verify: object,
|
|
994
|
+
* limits: object,
|
|
995
|
+
* evidence: { strictTestLock: boolean },
|
|
996
|
+
* isBootstrap: boolean,
|
|
997
|
+
* rawConfig: string | null,
|
|
998
|
+
* }}
|
|
999
|
+
*/
|
|
1000
|
+
export function resolveTrustedPolicy(root = process.cwd(), baseRef = null, mode = "working-tree", opts = {}) {
|
|
1001
|
+
// 1. Resolve base branch
|
|
1002
|
+
let base = baseRef || opts.base;
|
|
1003
|
+
if (!base) {
|
|
1004
|
+
if (mode === "committed") {
|
|
1005
|
+
base = detectDefaultBranch(root) || "main";
|
|
1006
|
+
} else {
|
|
1007
|
+
const diskConfig = opts.config || (existsSync(join(root, ".agent/config.yml")) ? loadConfig(root) : null);
|
|
1008
|
+
base = diskConfig?.baseBranch || detectDefaultBranch(root) || "main";
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// F08 Guard: In committed mode, base branch can never be HEAD
|
|
1013
|
+
// (Comparing a commit against itself would produce an empty diff and bypass the gate)
|
|
1014
|
+
if (mode === "committed" && (base === "HEAD" || base === "HEAD^0")) {
|
|
1015
|
+
return {
|
|
1016
|
+
ok: false,
|
|
1017
|
+
code: 3,
|
|
1018
|
+
error: 'Cannot evaluate committed diff against base "HEAD": comparing a revision to itself would bypass the safety gate (F08).',
|
|
1019
|
+
base,
|
|
1020
|
+
isBootstrap: false,
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
let trustedConfigRaw = null;
|
|
1025
|
+
let trustedJulesRaw = null;
|
|
1026
|
+
try {
|
|
1027
|
+
trustedConfigRaw = showFromOrigin(root, base, ".agent/config.yml");
|
|
1028
|
+
trustedJulesRaw = showFromOrigin(root, base, ".agent/jules.yml");
|
|
1029
|
+
} catch (_) {
|
|
1030
|
+
// If base branch cannot be resolved or show fails, let changedFiles report code 1
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const autoVerify = resolveVerify(root);
|
|
1034
|
+
|
|
1035
|
+
if (trustedConfigRaw || trustedJulesRaw) {
|
|
1036
|
+
const raw = trustedConfigRaw || trustedJulesRaw;
|
|
1037
|
+
let parsed = {};
|
|
1038
|
+
try {
|
|
1039
|
+
if (raw) parsed = parseYaml(raw) || {};
|
|
1040
|
+
} catch (_) {}
|
|
1041
|
+
|
|
1042
|
+
const trustedScope = normalizeScope(parsed);
|
|
1043
|
+
const trustedDiffKb = Number(parsed.limits?.diff_kb || parsed.limits?.diffKb) || 75;
|
|
1044
|
+
|
|
1045
|
+
const rawSetup = parsed.setup_cmd ?? parsed.verify?.setup;
|
|
1046
|
+
const rawTest = parsed.test_cmd ?? parsed.verify?.test;
|
|
1047
|
+
const rawLint = parsed.lint_cmd ?? parsed.verify?.lint;
|
|
1048
|
+
const rawFuzz = parsed.fuzz_cmd ?? parsed.verify?.fuzz;
|
|
1049
|
+
const rawInvariant = parsed.invariant_cmd ?? parsed.verify?.invariant;
|
|
1050
|
+
const rawE2e = parsed.e2e_cmd ?? parsed.verify?.e2e;
|
|
1051
|
+
const rawTeardown = parsed.teardown_cmd ?? parsed.verify?.teardown;
|
|
1052
|
+
const rawBuild = parsed.build_cmd ?? parsed.verify?.build;
|
|
1053
|
+
const rawUnit = parsed.verify?.unit;
|
|
1054
|
+
const verifyTimeoutMs = parsed.verify?.timeoutMs ?? parsed.verify?.timeout_ms ?? 300_000;
|
|
1055
|
+
|
|
1056
|
+
const mergedVerify = {
|
|
1057
|
+
stack: autoVerify.stack || "unknown",
|
|
1058
|
+
setup: rawSetup ?? autoVerify.setup ?? "",
|
|
1059
|
+
lint: rawLint ?? autoVerify.lint ?? "",
|
|
1060
|
+
test: rawTest ?? autoVerify.test,
|
|
1061
|
+
unit: rawUnit ?? rawTest ?? autoVerify.unit ?? autoVerify.test,
|
|
1062
|
+
fuzz: rawFuzz ?? autoVerify.fuzz ?? "",
|
|
1063
|
+
invariant: rawInvariant ?? autoVerify.invariant ?? "",
|
|
1064
|
+
e2e: rawE2e ?? autoVerify.e2e ?? "",
|
|
1065
|
+
teardown: rawTeardown ?? autoVerify.teardown ?? "",
|
|
1066
|
+
build: rawBuild ?? autoVerify.build,
|
|
1067
|
+
policy: parsed.verify?.policy ?? autoVerify.policy,
|
|
1068
|
+
scope: parsed.verify?.scope === "affected" ? "affected" : "global",
|
|
1069
|
+
required: parsed.verify?.required !== undefined ? parsed.verify.required !== false : true,
|
|
1070
|
+
minTests: parsed.verify?.minTests ?? parsed.verify?.min_tests ?? 1,
|
|
1071
|
+
tamperGuard: parsed.verify?.tamperGuard ?? parsed.verify?.tamper_guard ?? true,
|
|
1072
|
+
timeoutMs: Number.isFinite(Number(verifyTimeoutMs)) ? Number(verifyTimeoutMs) : 300_000,
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
const rawProfile = parsed.verify?.profile ?? parsed.profile ?? null;
|
|
1076
|
+
const explicitStages = parsed.verify?.stages ?? null;
|
|
1077
|
+
const profilePlan =
|
|
1078
|
+
!explicitStages && rawProfile
|
|
1079
|
+
? buildProfileStages(rawProfile, { stack: autoVerify.stack, verify: mergedVerify })
|
|
1080
|
+
: null;
|
|
1081
|
+
|
|
1082
|
+
const stages = explicitStages ?? profilePlan?.stages ?? null;
|
|
1083
|
+
|
|
1084
|
+
return {
|
|
1085
|
+
ok: true,
|
|
1086
|
+
base,
|
|
1087
|
+
scope: trustedScope,
|
|
1088
|
+
verify: {
|
|
1089
|
+
...mergedVerify,
|
|
1090
|
+
stages,
|
|
1091
|
+
profile: profilePlan?.profile ?? (rawProfile ? String(rawProfile).toLowerCase() : null),
|
|
1092
|
+
profileSkipped: profilePlan?.skipped ?? [],
|
|
1093
|
+
},
|
|
1094
|
+
limits: { diffKb: trustedDiffKb },
|
|
1095
|
+
evidence: {
|
|
1096
|
+
strictTestLock: parsed.evidence?.strict_test_lock ?? parsed.evidence?.strictTestLock ?? true,
|
|
1097
|
+
},
|
|
1098
|
+
isBootstrap: false,
|
|
1099
|
+
rawConfig: raw,
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Bootstrap mode: base has no configuration file
|
|
1104
|
+
// Inspect any proposed scaffold in this change (F06)
|
|
1105
|
+
let proposedConfig = null;
|
|
1106
|
+
let proposedJules = null;
|
|
1107
|
+
|
|
1108
|
+
try {
|
|
1109
|
+
if (mode === "committed") {
|
|
1110
|
+
const c = showFromOrigin(root, "HEAD", ".agent/config.yml");
|
|
1111
|
+
if (c) proposedConfig = parseYaml(c);
|
|
1112
|
+
const j = showFromOrigin(root, "HEAD", ".agent/jules.yml");
|
|
1113
|
+
if (j) proposedJules = parseYaml(j);
|
|
1114
|
+
} else {
|
|
1115
|
+
const configPath = join(root, ".agent/config.yml");
|
|
1116
|
+
if (existsSync(configPath)) proposedConfig = parseYaml(readFileSync(configPath, "utf-8"));
|
|
1117
|
+
const julesPath = join(root, ".agent/jules.yml");
|
|
1118
|
+
if (existsSync(julesPath)) proposedJules = parseYaml(readFileSync(julesPath, "utf-8"));
|
|
1119
|
+
}
|
|
1120
|
+
} catch (_) {}
|
|
1121
|
+
|
|
1122
|
+
const effectiveProposed = proposedConfig || proposedJules || opts.config || null;
|
|
1123
|
+
|
|
1124
|
+
if (proposedConfig || proposedJules) {
|
|
1125
|
+
const integrity = checkBootstrapPolicyIntegrity(root, proposedConfig, proposedJules, autoVerify);
|
|
1126
|
+
if (!integrity.ok) {
|
|
1127
|
+
return {
|
|
1128
|
+
ok: false,
|
|
1129
|
+
code: 3,
|
|
1130
|
+
error: integrity.error,
|
|
1131
|
+
base,
|
|
1132
|
+
scope: normalizeScope({}),
|
|
1133
|
+
verify: {
|
|
1134
|
+
...autoVerify,
|
|
1135
|
+
stages: null,
|
|
1136
|
+
required: true,
|
|
1137
|
+
tamperGuard: true,
|
|
1138
|
+
minTests: 1,
|
|
1139
|
+
timeoutMs: 300_000,
|
|
1140
|
+
},
|
|
1141
|
+
limits: { diffKb: 75 },
|
|
1142
|
+
evidence: { strictTestLock: true },
|
|
1143
|
+
isBootstrap: true,
|
|
1144
|
+
rawConfig: null,
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const pConfig = effectiveProposed || {};
|
|
1150
|
+
const rawSetup = pConfig.setup_cmd ?? pConfig.verify?.setup;
|
|
1151
|
+
const rawTest = pConfig.test_cmd ?? pConfig.verify?.test;
|
|
1152
|
+
const rawLint = pConfig.lint_cmd ?? pConfig.verify?.lint;
|
|
1153
|
+
const rawFuzz = pConfig.fuzz_cmd ?? pConfig.verify?.fuzz;
|
|
1154
|
+
const rawInvariant = pConfig.invariant_cmd ?? pConfig.verify?.invariant;
|
|
1155
|
+
const rawE2e = pConfig.e2e_cmd ?? pConfig.verify?.e2e;
|
|
1156
|
+
const rawTeardown = pConfig.teardown_cmd ?? pConfig.verify?.teardown;
|
|
1157
|
+
const rawBuild = pConfig.build_cmd ?? pConfig.verify?.build;
|
|
1158
|
+
const rawUnit = pConfig.verify?.unit;
|
|
1159
|
+
const verifyTimeoutMs = pConfig.verify?.timeoutMs ?? pConfig.verify?.timeout_ms ?? 300_000;
|
|
1160
|
+
|
|
1161
|
+
const mergedBootstrapVerify = {
|
|
1162
|
+
stack: autoVerify.stack || "unknown",
|
|
1163
|
+
setup: rawSetup ?? autoVerify.setup ?? "",
|
|
1164
|
+
lint: rawLint ?? autoVerify.lint ?? "",
|
|
1165
|
+
test: rawTest ?? autoVerify.test,
|
|
1166
|
+
unit: rawUnit ?? rawTest ?? autoVerify.unit ?? autoVerify.test,
|
|
1167
|
+
fuzz: rawFuzz ?? autoVerify.fuzz ?? "",
|
|
1168
|
+
invariant: rawInvariant ?? autoVerify.invariant ?? "",
|
|
1169
|
+
e2e: rawE2e ?? autoVerify.e2e ?? "",
|
|
1170
|
+
teardown: rawTeardown ?? autoVerify.teardown ?? "",
|
|
1171
|
+
build: rawBuild ?? autoVerify.build,
|
|
1172
|
+
policy: pConfig.verify?.policy ?? autoVerify.policy,
|
|
1173
|
+
scope: pConfig.verify?.scope === "affected" ? "affected" : "global",
|
|
1174
|
+
required: pConfig.verify?.required !== undefined ? pConfig.verify.required !== false : true,
|
|
1175
|
+
minTests: pConfig.verify?.minTests ?? pConfig.verify?.min_tests ?? 1,
|
|
1176
|
+
tamperGuard: pConfig.verify?.tamperGuard ?? pConfig.verify?.tamper_guard ?? true,
|
|
1177
|
+
timeoutMs: Number.isFinite(Number(verifyTimeoutMs)) ? Number(verifyTimeoutMs) : 300_000,
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
const rawProfile = pConfig.verify?.profile ?? pConfig.profile ?? null;
|
|
1181
|
+
const explicitStages = pConfig.verify?.stages ?? null;
|
|
1182
|
+
const profilePlan =
|
|
1183
|
+
!explicitStages && rawProfile
|
|
1184
|
+
? buildProfileStages(rawProfile, { stack: autoVerify.stack, verify: mergedBootstrapVerify })
|
|
1185
|
+
: null;
|
|
1186
|
+
|
|
1187
|
+
const stages = explicitStages ?? profilePlan?.stages ?? null;
|
|
1188
|
+
const trustedScope = normalizeScope(pConfig);
|
|
1189
|
+
const trustedLimits = {
|
|
1190
|
+
diffKb: Number(pConfig.limits?.diff_kb || pConfig.limits?.diffKb) || 75,
|
|
1191
|
+
};
|
|
1192
|
+
const trustedEvidence = {
|
|
1193
|
+
strictTestLock: pConfig.evidence?.strict_test_lock ?? pConfig.evidence?.strictTestLock ?? true,
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
return {
|
|
1197
|
+
ok: true,
|
|
1198
|
+
base,
|
|
1199
|
+
scope: trustedScope,
|
|
1200
|
+
verify: {
|
|
1201
|
+
...mergedBootstrapVerify,
|
|
1202
|
+
stages,
|
|
1203
|
+
profile: profilePlan?.profile ?? (rawProfile ? String(rawProfile).toLowerCase() : null),
|
|
1204
|
+
profileSkipped: profilePlan?.skipped ?? [],
|
|
1205
|
+
},
|
|
1206
|
+
limits: trustedLimits,
|
|
1207
|
+
evidence: trustedEvidence,
|
|
1208
|
+
isBootstrap: true,
|
|
1209
|
+
rawConfig: null,
|
|
1210
|
+
};
|
|
1211
|
+
}
|
package/src/coverage.mjs
CHANGED
|
@@ -31,6 +31,42 @@ export function isExcludedFromCoverage(filePath = "") {
|
|
|
31
31
|
return false;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Extensions V8 could have observed. Coverage outside this family is not a
|
|
36
|
+
* measurement that returned nothing — it is no measurement at all, and a
|
|
37
|
+
* hard red there is how the gate gets switched off.
|
|
38
|
+
*/
|
|
39
|
+
const V8_OBSERVABLE_EXT = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"]);
|
|
40
|
+
|
|
41
|
+
const isV8Observable = (file) => {
|
|
42
|
+
const dot = file.lastIndexOf(".");
|
|
43
|
+
return dot !== -1 && V8_OBSERVABLE_EXT.has(file.slice(dot).toLowerCase());
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Lines of source text that are executable on their own — the denominator a
|
|
48
|
+
* line coverage measure would count. V8's own mapper only emits entries for
|
|
49
|
+
* lines its function ranges *observed*; a file Node never imported has no
|
|
50
|
+
* functions at all, so every executable line in it was previously invisible
|
|
51
|
+
* and a 0/0 diff was reported as a passing 100% (F12). The heuristic stays
|
|
52
|
+
* deliberately conservative: comments, blank lines, and pure declarations
|
|
53
|
+
* (imports, exports of names alone) do not count.
|
|
54
|
+
*/
|
|
55
|
+
export function executableLineNumbers(sourceContent = "") {
|
|
56
|
+
const lines = sourceContent.split("\n");
|
|
57
|
+
const out = [];
|
|
58
|
+
for (let i = 0; i < lines.length; i++) {
|
|
59
|
+
const trimmed = lines[i].trim();
|
|
60
|
+
if (!trimmed) continue;
|
|
61
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) continue;
|
|
62
|
+
if (trimmed.startsWith("import ")) continue;
|
|
63
|
+
if (/^(?:export\s+)?(?:default\s+)?(?:class|function|const|let|var)\s+[A-Za-z_$][\w$]*\s*$/.test(trimmed)) continue;
|
|
64
|
+
if (trimmed === "{" || trimmed === "}" || trimmed === "};") continue;
|
|
65
|
+
out.push(i + 1);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
34
70
|
/**
|
|
35
71
|
* Maps V8 function range offsets to 1-indexed line hit counts.
|
|
36
72
|
* @param {string} sourceContent - Full text of source file
|
|
@@ -267,6 +303,11 @@ export function calculateDiffCoverage(coverageByFile, diffStr = "", options = {}
|
|
|
267
303
|
let totalLines = 0;
|
|
268
304
|
let coveredLines = 0;
|
|
269
305
|
const missedByFile = {};
|
|
306
|
+
// Files V8 can measure, but for which the test run produced no coverage
|
|
307
|
+
// data at all — code Node never imported. Distinct from files V8 cannot
|
|
308
|
+
// observe (Python, Go, …): those are genuinely not measurable here, and
|
|
309
|
+
// scoring them zero blocks stacks the gate never claimed to cover.
|
|
310
|
+
const unobservedExecutableFiles = [];
|
|
270
311
|
|
|
271
312
|
for (const [file, addedLines] of addedLinesMap.entries()) {
|
|
272
313
|
const absPath = resolve(root, file);
|
|
@@ -279,11 +320,20 @@ export function calculateDiffCoverage(coverageByFile, diffStr = "", options = {}
|
|
|
279
320
|
continue;
|
|
280
321
|
}
|
|
281
322
|
|
|
323
|
+
const v8Observable = isV8Observable(file);
|
|
282
324
|
const v8Functions = coverageByFile.get(file) || [];
|
|
283
325
|
const lineHits = mapV8RangesToLines(sourceContent, v8Functions);
|
|
284
326
|
|
|
327
|
+
// The executable denominator *from the source itself*, independent of
|
|
328
|
+
// whether V8 happened to map the file. A file that added executable
|
|
329
|
+
// lines but never appeared in V8's report ran nothing of what was added.
|
|
330
|
+
const executableSet = new Set(executableLineNumbers(sourceContent));
|
|
331
|
+
const neverObserved = v8Observable && v8Functions.length === 0 && addedLines.some((n) => executableSet.has(n));
|
|
332
|
+
if (neverObserved) unobservedExecutableFiles.push(file);
|
|
333
|
+
|
|
285
334
|
const missed = [];
|
|
286
335
|
for (const lineNo of addedLines) {
|
|
336
|
+
const isExecutable = executableSet.has(lineNo);
|
|
287
337
|
if (lineHits.has(lineNo)) {
|
|
288
338
|
totalLines++;
|
|
289
339
|
const count = lineHits.get(lineNo);
|
|
@@ -292,6 +342,13 @@ export function calculateDiffCoverage(coverageByFile, diffStr = "", options = {}
|
|
|
292
342
|
} else {
|
|
293
343
|
missed.push(lineNo);
|
|
294
344
|
}
|
|
345
|
+
} else if (neverObserved && isExecutable) {
|
|
346
|
+
// V8 could have seen this line, mapped nothing in the file, and the
|
|
347
|
+
// line is executable: it was not covered. Counting it is what turns
|
|
348
|
+
// "0/0 scored" for an unimported module into a real miss — the gate
|
|
349
|
+
// returning exit 0 on code no test ran is F12.
|
|
350
|
+
totalLines++;
|
|
351
|
+
missed.push(lineNo);
|
|
295
352
|
}
|
|
296
353
|
}
|
|
297
354
|
|
|
@@ -302,30 +359,45 @@ export function calculateDiffCoverage(coverageByFile, diffStr = "", options = {}
|
|
|
302
359
|
|
|
303
360
|
// 100% of nothing is not 100%.
|
|
304
361
|
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
362
|
+
// Two very different ways the denominator can be zero:
|
|
363
|
+
//
|
|
364
|
+
// 1. There were no executable added lines to measure, or the diff is in
|
|
365
|
+
// code V8 cannot observe (Python, Go, …). The measurement is simply
|
|
366
|
+
// not applicable here; `ok` stays true because nothing failed to be
|
|
367
|
+
// covered — a gate that blocks every non-Node diff gets switched off.
|
|
368
|
+
// What changes is the claim: `scored: false` and a reason.
|
|
369
|
+
// 2. There were executable lines in code V8 *can* observe, and the test
|
|
370
|
+
// run mapped none of them — the file was never imported. A passing
|
|
371
|
+
// result on that is the same failure as a line counted uncovered:
|
|
372
|
+
// the suite certified code it never ran (F12). That is scored zero,
|
|
373
|
+
// not scored nothing.
|
|
311
374
|
//
|
|
312
|
-
// `
|
|
313
|
-
//
|
|
314
|
-
|
|
375
|
+
// `mutation.mjs` had the original "100% of 0/0" bug in the first family
|
|
376
|
+
// and was fixed in v0.57.0; this is the second family, one module over.
|
|
377
|
+
const measuredNothingExecutable = unobservedExecutableFiles.length > 0 && totalLines === 0;
|
|
315
378
|
const scored = totalLines > 0;
|
|
316
379
|
const score = scored ? Math.round((coveredLines / totalLines) * 10000) / 100 : null;
|
|
317
|
-
|
|
380
|
+
// N/A only when V8 could not have measured the added code (no executable
|
|
381
|
+
// lines at all, or a stack V8 does not observe). Executable Node code the
|
|
382
|
+
// test run never reached is a failure, not an N/A.
|
|
383
|
+
const notApplicable = !scored && !measuredNothingExecutable;
|
|
384
|
+
const ok = notApplicable ? true : score >= minCoverage;
|
|
318
385
|
|
|
319
386
|
return {
|
|
320
387
|
ok,
|
|
321
|
-
score,
|
|
388
|
+
score: notApplicable ? null : score,
|
|
322
389
|
scored,
|
|
323
|
-
...(
|
|
390
|
+
...(notApplicable
|
|
391
|
+
? { reason: "No added executable lines were measurable — V8 coverage only observes code Node itself ran, so nothing was scored." }
|
|
392
|
+
: {
|
|
393
|
+
reason: `Added executable code was never executed by the test run (unobserved: ${unobservedExecutableFiles.join(", ")}) — V8 coverage saw no execution of it, so it counts as uncovered.`,
|
|
394
|
+
unobservedFiles: unobservedExecutableFiles,
|
|
395
|
+
}),
|
|
324
396
|
minCoverage,
|
|
325
397
|
totalLines,
|
|
326
398
|
coveredLines,
|
|
327
399
|
missedLines: totalLines - coveredLines,
|
|
328
400
|
missedByFile,
|
|
329
|
-
summary: `Diff Coverage: ${score}% (${coveredLines}/${totalLines} added executable lines covered, min: ${minCoverage}%)`,
|
|
401
|
+
summary: `Diff Coverage: ${notApplicable ? "null" : score}% (${coveredLines}/${totalLines} added executable lines covered, min: ${minCoverage}%)`,
|
|
330
402
|
};
|
|
331
403
|
}
|