promptwheel 0.4.6 → 0.4.7
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/bin/promptwheel.mjs +64 -13
- package/package.json +1 -1
package/bin/promptwheel.mjs
CHANGED
|
@@ -51,12 +51,12 @@ function resolveConfig(p, repo, label, seen) {
|
|
|
51
51
|
if (!existsSync(bp)) { console.error(`extends target not found: ${ref} (from ${relOf(repo, p)})`); process.exit(2); }
|
|
52
52
|
const base = resolveConfig(bp, repo, relOf(repo, bp), seen);
|
|
53
53
|
for (const m of base.metrics) byName.set(m.name, m);
|
|
54
|
-
for (const k of ['repeat', 'linkNodeModules', 'linkDirs', 'env', 'setup', 'record', 'gamingThreshold']) if (base[k] !== undefined) scalars[k] = base[k];
|
|
54
|
+
for (const k of ['repeat', 'linkNodeModules', 'linkDirs', 'env', 'setup', 'record', 'gamingThreshold', 'evaluatorPaths']) if (base[k] !== undefined) scalars[k] = base[k];
|
|
55
55
|
}
|
|
56
56
|
for (const m of (cfg.metrics || [])) {
|
|
57
57
|
byName.set(m.name, { ...m, __src: label, __override: byName.has(m.name) });
|
|
58
58
|
}
|
|
59
|
-
for (const k of ['repeat', 'linkNodeModules', 'linkDirs', 'env', 'setup', 'record', 'gamingThreshold']) if (cfg[k] !== undefined) scalars[k] = cfg[k];
|
|
59
|
+
for (const k of ['repeat', 'linkNodeModules', 'linkDirs', 'env', 'setup', 'record', 'gamingThreshold', 'evaluatorPaths']) if (cfg[k] !== undefined) scalars[k] = cfg[k];
|
|
60
60
|
return { ...scalars, metrics: [...byName.values()] };
|
|
61
61
|
} finally {
|
|
62
62
|
seen.delete(abs); // only ACTIVE ancestors are a cycle, a diamond (a base reached two ways) is fine
|
|
@@ -203,6 +203,17 @@ const NON_SOURCE = [
|
|
|
203
203
|
/\.(test|spec)\.[cm]?[jt]sx?$/i,
|
|
204
204
|
/(^|\/)tests?\.[cm]?[jt]sx?$/i, // a file literally named test.js / tests.ts
|
|
205
205
|
/(^|\/)test_[^/]*\.py$/i, /_test\.py$/i, /(^|\/)conftest\.py$/i,
|
|
206
|
+
// cross-language test-filename conventions, strong-signal tokens only (a dedicated
|
|
207
|
+
// _test / _spec / Test(s) suffix, or a file literally named test.<lang>), so production
|
|
208
|
+
// code like latest.c, Contest.java or speedtest.go is NOT swept into the evaluator slice.
|
|
209
|
+
// (Test DIRECTORIES are covered above for every language; this adds the by-filename
|
|
210
|
+
// conventions compiled langs use at repo root, a red-team finding: a weakened test.c /
|
|
211
|
+
// FooTest.java / handler_test.go was classified as source, so the cheat survived.)
|
|
212
|
+
/(^|\/)tests?\.(c|cc|cpp|cxx|go|rs|rb)$/i,
|
|
213
|
+
/(^|\/)test_[^/]*\.(c|cc|cpp|cxx|h|hpp|go|rs)$/i,
|
|
214
|
+
/_test\.(c|cc|cpp|cxx|h|hpp|go|rs|rb)$/i,
|
|
215
|
+
/_spec\.rb$/i,
|
|
216
|
+
/[a-z0-9](Test|Tests)\.(java|kt|kts|scala)$/,
|
|
206
217
|
/\.snap$/i, /(^|\/)golden[^/]*$/i, /\.golden$/i,
|
|
207
218
|
/(^|\/)(eval|grader|score)[^/]*\.[cm]?[jt]s$/i,
|
|
208
219
|
// config files only, require a .config/.conf/.setup segment so PRODUCTION source that
|
|
@@ -222,17 +233,41 @@ const NON_SOURCE = [
|
|
|
222
233
|
];
|
|
223
234
|
const isNonSource = (p) => NON_SOURCE.some((re) => re.test(p));
|
|
224
235
|
|
|
225
|
-
//
|
|
226
|
-
|
|
236
|
+
// user-declared evaluator surface: extra paths that DEFINE the gate (a generic threshold
|
|
237
|
+
// file, a budget, a CI limit) and so must be reverted in the source-only re-run exactly
|
|
238
|
+
// like a test/grader. Opt-in per repo via `evaluatorPaths`, NOT guessed, because a bare
|
|
239
|
+
// name is a classifier risk; the user names the files only they know are the goalposts.
|
|
240
|
+
// Glob semantics: a bare name matches that basename at any depth; a pattern with "/" is a
|
|
241
|
+
// path glob anchored at repo root; "*" stops at "/", "**" crosses it, "?" is one char; a
|
|
242
|
+
// leading "/" anchors to the root explicitly.
|
|
243
|
+
function globToRe(glob) {
|
|
244
|
+
let g = glob; const anchored = g.startsWith('/'); if (anchored) g = g.slice(1);
|
|
245
|
+
const body = g.replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex, keep * ? for globbing
|
|
246
|
+
.replace(/\*\*/g, '\x00').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]').replace(/\x00/g, '.*');
|
|
247
|
+
const pre = (anchored || glob.includes('/')) ? '^' : '(^|/)';
|
|
248
|
+
return new RegExp(`${pre}${body}$`);
|
|
249
|
+
}
|
|
250
|
+
function evaluatorMatcher(cfg) {
|
|
251
|
+
const pats = (cfg && Array.isArray(cfg.evaluatorPaths) ? cfg.evaluatorPaths : []).filter((s) => typeof s === 'string' && s.trim());
|
|
252
|
+
if (!pats.length) return () => false;
|
|
253
|
+
const res = pats.map(globToRe);
|
|
254
|
+
return (p) => res.some((re) => re.test(p));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// split the agent's changed files into production-source vs test/config/grader/golden.
|
|
258
|
+
// `isEval` is the repo's opt-in evaluator-path matcher, treated as non-source alongside
|
|
259
|
+
// the built-in classifier, so a cheat that only edits a declared gate file is caught.
|
|
260
|
+
function changedSourcePaths(repo, base, head, isEval = () => false) {
|
|
227
261
|
const all = git(['diff', '--name-only', base, head], repo).split('\n').filter(Boolean);
|
|
228
|
-
|
|
262
|
+
const nonSrc = (p) => isNonSource(p) || isEval(p);
|
|
263
|
+
return { source: all.filter((p) => !nonSrc(p)), nonSource: all.filter(nonSrc) };
|
|
229
264
|
}
|
|
230
265
|
|
|
231
266
|
// coarse change-location fingerprint for the outcome record: top source dirs of the diff
|
|
232
|
-
function subsystemsOf(repo, base, head) {
|
|
267
|
+
function subsystemsOf(repo, base, head, cfg) {
|
|
233
268
|
try {
|
|
234
269
|
const counts = {};
|
|
235
|
-
for (const p of changedSourcePaths(repo, base, head).source) {
|
|
270
|
+
for (const p of changedSourcePaths(repo, base, head, evaluatorMatcher(cfg)).source) {
|
|
236
271
|
const d = p.includes('/') ? p.slice(0, p.indexOf('/')) : '(root)';
|
|
237
272
|
counts[d] = (counts[d] || 0) + 1;
|
|
238
273
|
}
|
|
@@ -242,7 +277,7 @@ function subsystemsOf(repo, base, head) {
|
|
|
242
277
|
|
|
243
278
|
// measure `metric` in a worktree at `base` with ONLY the source slice of base→head applied.
|
|
244
279
|
function measureSourceOnly(repo, base, head, metric, cfg, repeat) {
|
|
245
|
-
const { source } = changedSourcePaths(repo, base, head);
|
|
280
|
+
const { source } = changedSourcePaths(repo, base, head, evaluatorMatcher(cfg));
|
|
246
281
|
if (!source.length) return { samples: [], hadSourceChange: false }; // a "win" with zero source edits = goalposts moved
|
|
247
282
|
const wt = mkdtempSync(join(tmpdir(), 'promptwheel-src-'));
|
|
248
283
|
git(['worktree', 'add', '--quiet', '--detach', wt, base], repo);
|
|
@@ -410,7 +445,7 @@ function gate(repo, opts) {
|
|
|
410
445
|
// label attributes a change-type, subsystems fingerprint WHERE the change landed.
|
|
411
446
|
cohort: opts.cohort ?? (process.env.CI ? 'ci' : 'local'),
|
|
412
447
|
...(opts.label ? { label: opts.label } : {}),
|
|
413
|
-
subsystems: subsystemsOf(repo, base, head),
|
|
448
|
+
subsystems: subsystemsOf(repo, base, head, cfg),
|
|
414
449
|
verdict, metrics,
|
|
415
450
|
};
|
|
416
451
|
if (!opts.noRecord && cfg.record !== false) recordOutcome(repo, report);
|
|
@@ -676,7 +711,7 @@ const PRESETS = {
|
|
|
676
711
|
{ name: 'tests_pass', cmd: '__TESTCMD__', extract: 'exit', direction: 'pass', guard: true },
|
|
677
712
|
{ name: 'test_count', cmd: 'grep -rIoE "\\b(it|test|describe) ?\\(|def test_" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=dist --exclude-dir=build --exclude-dir=coverage --exclude-dir=.next --exclude-dir=out --exclude-dir=lib-cov --exclude-dir=__pycache__ --exclude-dir=.pytest_cache --exclude-dir=target --exclude-dir=.venv --exclude-dir=venv --exclude=promptwheel.config.json . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'up', guard: true, gamingCheck: false },
|
|
678
713
|
{ name: 'skipped_tests', cmd: 'grep -rIoE "\\.(skip|only) ?\\(|xit ?\\(|@pytest\\.mark\\.skip" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=dist --exclude-dir=build --exclude-dir=coverage --exclude-dir=.next --exclude-dir=out --exclude-dir=lib-cov --exclude-dir=__pycache__ --exclude-dir=.pytest_cache --exclude-dir=target --exclude-dir=.venv --exclude-dir=venv --exclude=promptwheel.config.json . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'down', guard: true, gamingCheck: false },
|
|
679
|
-
{ name: 'suppressions', cmd: 'grep -rIoE "eslint-disable|@ts-(ignore|nocheck)|# ?type: ?ignore|# ?noqa|//nolint|#!?\\[allow\\(" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=dist --exclude-dir=build --exclude-dir=coverage --exclude-dir=.next --exclude-dir=out --exclude-dir=lib-cov --exclude-dir=__pycache__ --exclude-dir=.pytest_cache --exclude-dir=target --exclude-dir=.venv --exclude-dir=venv --exclude=promptwheel.config.json . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'down', guard: true, gamingCheck: false },
|
|
714
|
+
{ name: 'suppressions', cmd: 'grep -rIoE "eslint-disable|@ts-(ignore|nocheck)|# ?type: ?ignore|# ?noqa|# ?nosec|//nolint|#!?\\[allow\\(" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=dist --exclude-dir=build --exclude-dir=coverage --exclude-dir=.next --exclude-dir=out --exclude-dir=lib-cov --exclude-dir=__pycache__ --exclude-dir=.pytest_cache --exclude-dir=target --exclude-dir=.venv --exclude-dir=venv --exclude=promptwheel.config.json . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'down', guard: true, gamingCheck: false },
|
|
680
715
|
{ name: 'assertions', cmd: 'grep -rIoE "expect ?\\(|\\bassert|\\bt\\.(is|deepEqual|throws|truthy|falsy|not|ok) ?\\(" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=dist --exclude-dir=build --exclude-dir=coverage --exclude-dir=.next --exclude-dir=out --exclude-dir=lib-cov --exclude-dir=__pycache__ --exclude-dir=.pytest_cache --exclude-dir=target --exclude-dir=.venv --exclude-dir=venv --exclude=promptwheel.config.json . 2>/dev/null | wc -l | tr -d " "', extract: 'number', direction: 'up', guard: true, gamingCheck: false },
|
|
681
716
|
] },
|
|
682
717
|
'no-ai-tells': { desc: 'opt-in: fail the gate if the em-dash count rises (an AI writing tell); some projects use them on purpose, so it is not a default',
|
|
@@ -685,6 +720,21 @@ const PRESETS = {
|
|
|
685
720
|
] },
|
|
686
721
|
};
|
|
687
722
|
|
|
723
|
+
// security-antihack = the security-invariant findings gate + the SAME antihack tripwires
|
|
724
|
+
// (so an agent that suppresses a security rule, # noqa / # nosec / eslint-disable, or
|
|
725
|
+
// skips a security test is caught), PLUS a starter `evaluatorPaths` that protects common
|
|
726
|
+
// security-gate config files (a ruleset or a budget the agent could otherwise loosen to
|
|
727
|
+
// pass). The scanner pack installs into `.promptwheel/` (already evaluator-protected). It
|
|
728
|
+
// is the "guard the guards" preset: pairs with --detect-gaming so a win that only moved by
|
|
729
|
+
// loosening the security gate, not by fixing the code, is GAMED. Blind spot (by design,
|
|
730
|
+
// not this tool's job): a source-resident backdoor that behaves safely only under test;
|
|
731
|
+
// that needs held-out/behavioral analysis, not a source-only re-run.
|
|
732
|
+
PRESETS['security-antihack'] = {
|
|
733
|
+
desc: 'gate security-invariant findings + antihack tripwires + evaluatorPaths so an agent cannot loosen the security gate to pass it (pairs with --detect-gaming)',
|
|
734
|
+
evaluatorPaths: ['.semgrep.yml', '.semgrep.yaml', '.bandit', 'bandit.yaml', '.ruff.toml', 'ruff.toml', '.snyk', 'security-budget.json'],
|
|
735
|
+
metrics: [{ name: 'security_findings', cmd: 'node packs/security/scan.mjs .', extract: 'number', direction: 'down', guard: true }, ...PRESETS.antihack.metrics.slice(1)],
|
|
736
|
+
};
|
|
737
|
+
|
|
688
738
|
function detectTestCmd(repo) {
|
|
689
739
|
const has = (f) => existsSync(join(repo, f));
|
|
690
740
|
if (has('go.mod')) return 'go test ./...';
|
|
@@ -734,10 +784,11 @@ function init(argv) {
|
|
|
734
784
|
}
|
|
735
785
|
const pi = argv.indexOf('--preset');
|
|
736
786
|
const presetName = pi >= 0 ? argv[pi + 1] : null;
|
|
737
|
-
let metrics, note;
|
|
787
|
+
let metrics, note, evaluatorPaths;
|
|
738
788
|
if (presetName) {
|
|
739
789
|
const p = PRESETS[presetName];
|
|
740
790
|
if (!p) { console.error(`unknown preset "${presetName}", try: promptwheel init --list`); process.exit(2); }
|
|
791
|
+
evaluatorPaths = p.evaluatorPaths; // a preset may ship a starter evaluator allowlist (e.g. security-antihack)
|
|
741
792
|
metrics = p.metrics || [{ name: 'tests_pass', cmd: detectTestCmd(repo), extract: 'exit', direction: 'pass', guard: true }];
|
|
742
793
|
metrics = metrics.map((m) => m.cmd === '__TESTCMD__' ? { ...m, cmd: detectTestCmd(repo) } : m); // fill the preset's target
|
|
743
794
|
// pack-backed presets: the referenced scripts live in THIS package, not
|
|
@@ -766,7 +817,7 @@ function init(argv) {
|
|
|
766
817
|
}
|
|
767
818
|
const envCfg = presetName ? {} : detectEnv(repo);
|
|
768
819
|
if (envCfg.linkDirs) note += ` · linking ${envCfg.linkDirs.join(', ')}`;
|
|
769
|
-
writeFileSync(out, JSON.stringify({ repeat: 1, ...envCfg, metrics }, null, 2) + '\n');
|
|
820
|
+
writeFileSync(out, JSON.stringify({ repeat: 1, ...envCfg, ...(evaluatorPaths ? { evaluatorPaths } : {}), metrics }, null, 2) + '\n');
|
|
770
821
|
console.log(`✓ wrote promptwheel.config.json, ${note}`);
|
|
771
822
|
console.log('\n next: promptwheel run --working # gate your uncommitted changes');
|
|
772
823
|
console.log(' promptwheel init --list # other presets (llm-eval, bundle-size, …)');
|
|
@@ -863,7 +914,7 @@ function parseArgs(argv) {
|
|
|
863
914
|
|
|
864
915
|
// pure, side-effect-free helpers are exported for unit testing; the CLI below only
|
|
865
916
|
// runs when this file is executed directly (not when imported by the test suite).
|
|
866
|
-
export { extract, evaluate, median, spread, renderMarkdown, isNonSource, gain, judgeGaming, foldOutcomes, betaMean, scoreKey };
|
|
917
|
+
export { extract, evaluate, median, spread, renderMarkdown, isNonSource, evaluatorMatcher, gain, judgeGaming, foldOutcomes, betaMean, scoreKey };
|
|
867
918
|
|
|
868
919
|
function main() {
|
|
869
920
|
const [cmd, ...rest] = process.argv.slice(2);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "promptwheel",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"description": "A deterministic, zero-LLM check for your coding loop. It proves every change earned its win by re-running the result from the agent's source edits alone, so a green that came from editing the test reads GAMED. Runs in CI or a Claude Code hook.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|