@whitehatd/crag 0.2.7 → 0.2.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whitehatd/crag",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "The bedrock layer for AI coding agents. One governance.md. Any project. Never stale.",
5
5
  "bin": {
6
6
  "crag": "bin/crag.js"
@@ -21,7 +21,7 @@
21
21
  const fs = require('fs');
22
22
  const path = require('path');
23
23
  const { execSync } = require('child_process');
24
- const { parseGovernance, flattenGates } = require('../governance/parse');
24
+ const { parseGovernance, flattenGates, extractSection } = require('../governance/parse');
25
25
  const { isModified, readFrontmatter } = require('../update/integrity');
26
26
  const { EXIT_USER, EXIT_INTERNAL } = require('../cli-errors');
27
27
 
@@ -381,6 +381,68 @@ function diagnoseHooks(cwd) {
381
381
  // Section: Drift (reuses crag diff logic)
382
382
  // ============================================================================
383
383
 
384
+ /**
385
+ * Detect the branch strategy declared in a governance.md document.
386
+ *
387
+ * Scopes the text scan to the `## Branch Strategy` section (avoiding false
388
+ * matches against unrelated prose). Within that section, the FIRST keyword
389
+ * to appear wins — this matches human reading order where the opening
390
+ * statement is the rule and later lines are qualifications.
391
+ *
392
+ * Returns 'feature-branches', 'trunk-based', or null.
393
+ *
394
+ * Exported for unit testing.
395
+ */
396
+ function detectBranchStrategy(content) {
397
+ if (typeof content !== 'string' || content.length === 0) return null;
398
+ const section = extractSection(content, 'Branch Strategy');
399
+ const scope = section || content; // fall back to whole file if section absent
400
+ const featureIdx = scope.search(/[Ff]eature branches/);
401
+ const trunkIdx = scope.search(/[Tt]runk-based/);
402
+ if (featureIdx === -1 && trunkIdx === -1) return null;
403
+ if (featureIdx === -1) return 'trunk-based';
404
+ if (trunkIdx === -1) return 'feature-branches';
405
+ return featureIdx < trunkIdx ? 'feature-branches' : 'trunk-based';
406
+ }
407
+
408
+ /**
409
+ * Given the raw output of `git branch -a --format="%(refname:short)"`, return
410
+ * the list of unique feature branches (by short name, after stripping any
411
+ * remote prefix).
412
+ *
413
+ * This is the piece that decides whether a repo is practicing feature-branch
414
+ * development. A repo whose LOCAL branches have all been merged+deleted but
415
+ * whose REMOTE still has open feat/* branches is absolutely still practicing
416
+ * feature-branch development — so we count remote-tracking refs as equivalent
417
+ * to local branches, and we dedupe.
418
+ *
419
+ * Exported for unit testing — avoids the need to set up a real git fixture.
420
+ */
421
+ const FEATURE_PREFIXES = ['feat', 'fix', 'docs', 'chore', 'feature', 'hotfix'];
422
+
423
+ function countFeatureBranches(gitBranchOutput) {
424
+ if (typeof gitBranchOutput !== 'string' || gitBranchOutput.length === 0) {
425
+ return [];
426
+ }
427
+ const rawList = gitBranchOutput
428
+ .split('\n')
429
+ .map(b => b.trim())
430
+ .filter(Boolean)
431
+ // Skip symbolic refs like "origin/HEAD" (no payload branch underneath).
432
+ .filter(b => !b.endsWith('/HEAD'));
433
+
434
+ const prefixGroup = FEATURE_PREFIXES.join('|');
435
+ const remoteStripRe = new RegExp(`^[A-Za-z0-9_.-]+/((?:${prefixGroup})/.+)$`);
436
+ const featureRe = new RegExp(`^(${prefixGroup})/`);
437
+
438
+ const normalized = rawList.map(b => {
439
+ const m = b.match(remoteStripRe);
440
+ return m ? m[1] : b;
441
+ });
442
+ const unique = [...new Set(normalized)];
443
+ return unique.filter(b => featureRe.test(b));
444
+ }
445
+
384
446
  function diagnoseDrift(cwd) {
385
447
  const checks = [];
386
448
  const govPath = path.join(cwd, '.claude', 'governance.md');
@@ -390,20 +452,19 @@ function diagnoseDrift(cwd) {
390
452
 
391
453
  const content = fs.readFileSync(govPath, 'utf-8');
392
454
 
393
- // Branch strategy alignment
394
- const govBranchStrategy = content.includes('Feature branches') || content.includes('feature branches')
395
- ? 'feature-branches'
396
- : content.includes('Trunk-based') || content.includes('trunk-based')
397
- ? 'trunk-based'
398
- : null;
455
+ // Branch strategy alignment — detect from the `## Branch Strategy` section
456
+ // rather than the whole file so unrelated prose ("feature branches in each
457
+ // sub-repo") doesn't override a workspace wrapper's actual trunk-based
458
+ // policy. Within that section, the FIRST keyword to appear wins: this
459
+ // matches the human reading order where the opening bullet states the rule.
460
+ const govBranchStrategy = detectBranchStrategy(content);
399
461
 
400
462
  if (govBranchStrategy) {
401
463
  try {
402
464
  const branches = execSync('git branch -a --format="%(refname:short)"', {
403
465
  cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
404
466
  });
405
- const list = branches.trim().split('\n');
406
- const featureBranches = list.filter(b => /^(feat|fix|docs|chore|feature|hotfix)\//.test(b));
467
+ const featureBranches = countFeatureBranches(branches);
407
468
  const actualStrategy = featureBranches.length > 2 ? 'feature-branches' : 'trunk-based';
408
469
 
409
470
  checks.push({
@@ -583,4 +644,4 @@ function printReport(report, { ciMode = false, strict = false } = {}) {
583
644
  }
584
645
  }
585
646
 
586
- module.exports = { doctor, runDiagnostics };
647
+ module.exports = { doctor, runDiagnostics, countFeatureBranches, detectBranchStrategy };
@@ -107,8 +107,40 @@ function parseGovernance(content) {
107
107
  if (gatesBody) {
108
108
  let section = 'default';
109
109
  let sectionMeta = { path: null, condition: null };
110
+ // Fenced code blocks (```bash / ```sh / ```shell) are treated as an
111
+ // alternative command carrier: every non-blank, non-comment line inside
112
+ // is extracted as a MANDATORY command for the current section. This
113
+ // matches the very common markdown pattern of documenting gate commands
114
+ // in a bash fence instead of a bullet list.
115
+ let inCodeBlock = false;
110
116
 
111
117
  for (const line of gatesBody.split('\n')) {
118
+ // Fence toggle. Accepts ``` , ```bash , ```sh , ```shell (and any other
119
+ // language tag) — we treat all fenced blocks inside ## Gates as gate
120
+ // command carriers. A non-shell language tag would be unusual here.
121
+ if (/^\s*```/.test(line)) {
122
+ inCodeBlock = !inCodeBlock;
123
+ continue;
124
+ }
125
+
126
+ if (inCodeBlock) {
127
+ const cmd = line.trim();
128
+ // Skip blanks and pure comments. Do NOT strip inline comments from
129
+ // the tail of a command — shell parsers treat `#` mid-line as a
130
+ // comment only when preceded by whitespace, and losing the rest of
131
+ // the line could silently drop logic like `echo "# header"`.
132
+ if (!cmd || cmd.startsWith('#')) continue;
133
+ if (!result.gates[section]) {
134
+ result.gates[section] = {
135
+ commands: [],
136
+ path: sectionMeta.path,
137
+ condition: sectionMeta.condition,
138
+ };
139
+ }
140
+ result.gates[section].commands.push({ cmd, classification: 'MANDATORY' });
141
+ continue;
142
+ }
143
+
112
144
  // Match ### Section or ### Section (path: dir/) or ### Section (if: file)
113
145
  const sub = line.match(/^### (.+?)(?:\s*\((?:(path|if):\s*(.+?))\))?\s*$/);
114
146
  if (sub) {