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.
Files changed (2) hide show
  1. package/bin/promptwheel.mjs +64 -13
  2. package/package.json +1 -1
@@ -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
- // split the agent's changed files into production-source vs test/config/grader/golden
226
- function changedSourcePaths(repo, base, head) {
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
- return { source: all.filter((p) => !isNonSource(p)), nonSource: all.filter(isNonSource) };
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.6",
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": {