@rmyndharis/aimhooman 0.1.1 → 0.1.2

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/src/hook.mjs CHANGED
@@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process';
3
3
  import { homedir } from 'node:os';
4
4
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { GIT_TIMEOUT_MS } from './git-environment.mjs';
6
7
  import { newEngineWithDiagnostics } from './scan.mjs';
7
8
  import { openRepo, stagedEntries } from './gitx.mjs';
8
9
  import { applyExclude, patternsForRules } from './exclude.mjs';
@@ -330,7 +331,11 @@ function hookPreToolUse(input) {
330
331
  }
331
332
  }
332
333
  let eng;
334
+ // diagnosticWarning is about the rule pack and drives the messages below;
335
+ // hygieneWarning is housekeeping that never touches the decision. Kept apart
336
+ // so the "invalid local pack skipped" wording stays true.
333
337
  let diagnosticWarning = '';
338
+ let hygieneWarning = '';
334
339
  try {
335
340
  const loaded = repo
336
341
  ? engineForPolicy(repo, policy, policy.head)
@@ -343,7 +348,6 @@ function hookPreToolUse(input) {
343
348
  return emitDecision('deny', `aimhooman strict policy could not load local rules: ${diagnosticWarning}`);
344
349
  }
345
350
  }
346
- if (repo) applyExclude(repo.excludeFile, patternsForRules(eng.rules));
347
351
  } catch (e) {
348
352
  const reason = e?.name === 'LocalOverridesError'
349
353
  ? `aimhooman cannot load local overrides: ${e.message}`
@@ -352,6 +356,17 @@ function hookPreToolUse(input) {
352
356
  if (profile === 'strict') return emitDecision('deny', reason);
353
357
  return emitDecision('allow', `${reason}; continuing because profile=${profile}`);
354
358
  }
359
+ // Refreshing the excludes is gitignore hygiene, not part of the verdict:
360
+ // pre-commit never writes them and still answers. Kept out of the block
361
+ // above so a read-only .git/info (CI checkout, read-only volume, a
362
+ // repository owned by another user) cannot decide what is allowed.
363
+ if (repo) {
364
+ try {
365
+ applyExclude(repo.excludeFile, patternsForRules(eng.rules));
366
+ } catch (e) {
367
+ hygieneWarning = `could not refresh ${repo.excludeFile}: ${e.message}`;
368
+ }
369
+ }
355
370
  // A strict policy cannot make a meaningful guarantee if Git's own guards
356
371
  // are explicitly bypassed. Deny before the shell can stage-and-commit in a
357
372
  // single command, when inspecting the future index would be impossible.
@@ -468,6 +483,9 @@ function hookPreToolUse(input) {
468
483
  if (diagnosticWarning) {
469
484
  return emitDecision('allow', `aimhooman warning: ${diagnosticWarning}; invalid local pack skipped`);
470
485
  }
486
+ if (hygieneWarning) {
487
+ return emitDecision('allow', `aimhooman warning: ${hygieneWarning}`);
488
+ }
471
489
  return 0;
472
490
  }
473
491
 
@@ -495,10 +513,8 @@ function hookPreToolUse(input) {
495
513
  }
496
514
  const guarded = repo && !bypassed && installedHooks(repo).includes('pre-commit');
497
515
  const advisory = advisoryReason(blocks, guarded, bypassed ? bypassContext : '');
498
- return emitDecision(
499
- 'allow',
500
- diagnosticWarning ? `${advisory} Warning: ${diagnosticWarning}.` : advisory
501
- );
516
+ const notes = [diagnosticWarning, hygieneWarning].filter(Boolean).join('; ');
517
+ return emitDecision('allow', notes ? `${advisory} Warning: ${notes}.` : advisory);
502
518
  }
503
519
 
504
520
  function toolName(i) {
@@ -1213,7 +1229,12 @@ function configuredPushReceiver(repo) {
1213
1229
  return execFileSync(
1214
1230
  'git',
1215
1231
  ['config', '--get-regexp', '^remote\\..*\\.receivepack$'],
1216
- { cwd: repo.root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
1232
+ {
1233
+ cwd: repo.root,
1234
+ encoding: 'utf8',
1235
+ stdio: ['ignore', 'pipe', 'ignore'],
1236
+ timeout: GIT_TIMEOUT_MS,
1237
+ },
1217
1238
  ).trim().length > 0;
1218
1239
  } catch (error) {
1219
1240
  if (error?.status === 1) return false;
@@ -1448,6 +1469,7 @@ function readGitAlias(cwd, name) {
1448
1469
  cwd,
1449
1470
  encoding: 'utf8',
1450
1471
  stdio: ['ignore', 'pipe', 'ignore'],
1472
+ timeout: GIT_TIMEOUT_MS,
1451
1473
  }).trim();
1452
1474
  } catch {
1453
1475
  return null;
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { gitEnvironment } from './git-environment.mjs';
2
+ import { gitEnvironment, GIT_TIMEOUT_MS } from './git-environment.mjs';
3
3
 
4
4
  export const DEFAULT_SCAN_LIMITS = Object.freeze({
5
5
  maxFileBytes: 2 << 20,
@@ -112,6 +112,7 @@ function readObjects(repo, objectIds, expectedBytes = 0) {
112
112
  input: Buffer.from(unique.join('\n') + '\n'),
113
113
  encoding: 'buffer',
114
114
  maxBuffer: Math.max(2 * 1024 * 1024, expectedBytes + unique.length * 256 + 1024),
115
+ timeout: GIT_TIMEOUT_MS,
115
116
  });
116
117
  const objects = new Map();
117
118
  const failures = [];
@@ -49,6 +49,7 @@ export function scanMessage(repo, text, options = {}) {
49
49
  const { policy, head } = resolved;
50
50
  const loaded = engineForPolicy(repo, policy, options.overrideHead || head);
51
51
  const accumulator = createAccumulator(options.limits);
52
+ accumulator.addSkipped(loaded.skipped);
52
53
  accumulator.add(loaded.engine.checkMessage(text).map((finding) => decorate(finding, null, policy)));
53
54
  accumulator.addSkipped(loaded.engine.takeSkipped());
54
55
  return {
@@ -68,6 +69,7 @@ function scanIndex(repo, options) {
68
69
  const { rawPolicy, floor, policy, head, acknowledged } = resolveStagedPolicy(repo, options.explicitProfile);
69
70
  const loaded = engineForPolicy(repo, policy, options.overrideHead || head);
70
71
  const accumulator = createAccumulator(options.limits);
72
+ accumulator.addSkipped(loaded.skipped);
71
73
  let entries = kind === 'tracked' ? trackedEntries(repo) : stagedEntries(repo);
72
74
  const conflicts = unmergedPaths(repo);
73
75
  const diagnostics = [...loaded.diagnostics];
@@ -189,6 +191,7 @@ function scanCommit(repo, options) {
189
191
  reviewContexts,
190
192
  );
191
193
  const accumulator = createAccumulator(options.limits);
194
+ accumulator.addSkipped(loaded.skipped);
192
195
  const diagnostics = [...loaded.diagnostics];
193
196
 
194
197
  if (snapshot.shallowBoundary) {
@@ -261,6 +264,9 @@ function scanRange(repo, options) {
261
264
  // only constructs an Engine and applies overrides (see engineForPolicy).
262
265
  const preloaded = loadRulesWithDiagnostics(repo.stateDir);
263
266
  const preloadedOverrides = loadOverrides(repo.stateDir);
267
+ // Counted once for the range, not once per commit: the same packs back every
268
+ // commit's engine, so the per-commit loaded.skipped would inflate the tally.
269
+ accumulator.addSkipped(packSkipped(preloaded.errors));
264
270
 
265
271
  for (const commit of history.commits) {
266
272
  const changes = commitChanges(repo, commit.commit, commit.commit, commit.parents);
@@ -452,7 +458,14 @@ export function engineForPolicy(
452
458
  [...reviewedInstructions, ...reviewedPolicies],
453
459
  );
454
460
  const diagnostics = loaded.errors.map((error) => ({ level: 'warning', message: `${error.message}; pack skipped` }));
455
- return { engine: loaded.engine, diagnostics };
461
+ return { engine: loaded.engine, diagnostics, skipped: packSkipped(loaded.errors) };
462
+ }
463
+
464
+ // packSkipped turns rule-pack load failures into a counted skip reason. A pack
465
+ // that never loaded is a hole in coverage, not an empty result: strict throws
466
+ // above, and clean/compliance proceed but must not report a complete scan.
467
+ function packSkipped(errors) {
468
+ return errors.length ? { 'local-pack-error': errors.length } : {};
456
469
  }
457
470
 
458
471
  function reviewedEngineEntry(entry, ruleId, transition) {
@@ -589,6 +602,10 @@ function rangeReportPolicy(basePolicy, policies, usedStrictFloor, explicitProfil
589
602
  };
590
603
  }
591
604
 
605
+ // Skip reasons that mean a rule never ran, as opposed to a rule running and
606
+ // matching nothing. Either way the scan cannot claim to have covered its input.
607
+ const INCOMPLETE_SKIP_REASONS = new Set(['local-input-limit', 'local-pack-error']);
608
+
592
609
  function createAccumulator(limits = {}) {
593
610
  const effectiveLimits = { ...DEFAULT_SCAN_LIMITS, ...limits };
594
611
  const findings = [];
@@ -644,7 +661,7 @@ function createAccumulator(limits = {}) {
644
661
  addSkipped(skipped = {}) {
645
662
  for (const [reason, count] of Object.entries(skipped)) {
646
663
  stats.skipped[reason] = (stats.skipped[reason] || 0) + count;
647
- if (reason === 'local-input-limit') complete = false;
664
+ if (INCOMPLETE_SKIP_REASONS.has(reason)) complete = false;
648
665
  }
649
666
  },
650
667
  markIncomplete() {