@whitehatd/crag 0.2.9 → 0.2.10
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 +1 -1
- package/src/analyze/stacks.js +10 -0
- package/src/commands/analyze.js +17 -2
- package/src/commands/diff.js +3 -7
- package/src/commands/doctor.js +87 -65
- package/src/crag-agent.md +1 -1
- package/src/governance/drift-utils.js +107 -0
- package/src/governance/yaml-run.js +41 -0
- package/src/skills/post-start-validation.md +1 -1
- package/src/skills/pre-start-context.md +1 -1
package/package.json
CHANGED
package/src/analyze/stacks.js
CHANGED
|
@@ -145,8 +145,18 @@ function detectStack(dir, result, options = {}) {
|
|
|
145
145
|
*/
|
|
146
146
|
function detectNestedStacks(dir, result) {
|
|
147
147
|
const containerDirs = [
|
|
148
|
+
// Classic containers (monorepos, microservice demos, VSCode-style editors)
|
|
148
149
|
'src', 'services', 'packages', 'apps', 'cmd', 'projects', 'microservices',
|
|
149
150
|
'sdk', 'sdks', 'web', 'ui', 'editors', 'extensions', 'clients',
|
|
151
|
+
// Monolith tier-naming (backend/frontend/mobile layouts are extremely
|
|
152
|
+
// common in single-repo full-stack projects — e.g. Leyoda, Metabase,
|
|
153
|
+
// many Y Combinator startups). Without these, `crag analyze` on a
|
|
154
|
+
// top-level monolith root reports `Stack: unknown` because no root
|
|
155
|
+
// manifest exists and none of the classic containers are present.
|
|
156
|
+
'backend', 'frontend', 'api', 'client', 'server', 'worker', 'workers',
|
|
157
|
+
'mobile', 'ios', 'android', 'desktop', 'cli', 'lib', 'libs', 'shared',
|
|
158
|
+
// Common AI / data pipeline layouts
|
|
159
|
+
'signal-engine', 'pipelines', 'ml', 'models', 'agents', 'notebooks',
|
|
150
160
|
];
|
|
151
161
|
|
|
152
162
|
const rootHadStacks = result.stack.length > 0;
|
package/src/commands/analyze.js
CHANGED
|
@@ -136,9 +136,24 @@ function filterFixtureMembers(members) {
|
|
|
136
136
|
});
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Sanitize a directory basename for use as a project name.
|
|
141
|
+
*
|
|
142
|
+
* Drops leading non-alphanumerics (`- Leyoda` → `Leyoda`), trims surrounding
|
|
143
|
+
* whitespace, and collapses internal whitespace runs. Leaves interior dashes
|
|
144
|
+
* and underscores intact (`my-cool-project` stays as-is). If the result is
|
|
145
|
+
* empty (pathological input like `---` or ` `), falls back to the literal
|
|
146
|
+
* basename so `crag analyze` never produces an empty `- Project:` line.
|
|
147
|
+
*/
|
|
148
|
+
function sanitizeProjectName(basename) {
|
|
149
|
+
if (typeof basename !== 'string') return String(basename || 'unnamed');
|
|
150
|
+
const trimmed = basename.trim().replace(/^[^A-Za-z0-9]+/, '').replace(/\s+/g, ' ').trim();
|
|
151
|
+
return trimmed.length > 0 ? trimmed : basename;
|
|
152
|
+
}
|
|
153
|
+
|
|
139
154
|
function analyzeProject(dir) {
|
|
140
155
|
const result = {
|
|
141
|
-
name: path.basename(dir),
|
|
156
|
+
name: sanitizeProjectName(path.basename(dir)),
|
|
142
157
|
description: '',
|
|
143
158
|
stack: [],
|
|
144
159
|
linters: [],
|
|
@@ -450,4 +465,4 @@ function mergeWithExisting(existing, generated) {
|
|
|
450
465
|
return existing;
|
|
451
466
|
}
|
|
452
467
|
|
|
453
|
-
module.exports = { analyze, analyzeProject, isGateCommand, mergeWithExisting };
|
|
468
|
+
module.exports = { analyze, analyzeProject, sanitizeProjectName, isGateCommand, mergeWithExisting };
|
package/src/commands/diff.js
CHANGED
|
@@ -5,6 +5,7 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { parseGovernance, flattenGates } = require('../governance/parse');
|
|
7
7
|
const { extractRunCommands, isGateCommand } = require('../governance/yaml-run');
|
|
8
|
+
const { detectBranchStrategy, classifyGitBranchStrategy } = require('../governance/drift-utils');
|
|
8
9
|
const { cliError, EXIT_USER, EXIT_INTERNAL, readFileOrExit } = require('../cli-errors');
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -102,17 +103,12 @@ function checkGateReality(cwd, cmd) {
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
function checkBranchStrategy(cwd, content, results) {
|
|
105
|
-
const govStrategy =
|
|
106
|
-
? 'feature-branches' : content.includes('Trunk-based') || content.includes('trunk-based')
|
|
107
|
-
? 'trunk-based' : null;
|
|
108
|
-
|
|
106
|
+
const govStrategy = detectBranchStrategy(content);
|
|
109
107
|
if (!govStrategy) return;
|
|
110
108
|
|
|
111
109
|
try {
|
|
112
110
|
const branches = execSync('git branch -a --format="%(refname:short)"', { cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
113
|
-
const
|
|
114
|
-
const featureBranches = list.filter(b => /^(feat|fix|docs|chore|feature|hotfix)\//.test(b));
|
|
115
|
-
const actual = featureBranches.length > 2 ? 'feature-branches' : 'trunk-based';
|
|
111
|
+
const actual = classifyGitBranchStrategy(branches);
|
|
116
112
|
|
|
117
113
|
if (actual !== govStrategy) {
|
|
118
114
|
console.log(` \x1b[33mDRIFT\x1b[0m Branch strategy: governance says ${govStrategy}, git shows ${actual}`);
|
package/src/commands/doctor.js
CHANGED
|
@@ -21,8 +21,11 @@
|
|
|
21
21
|
const fs = require('fs');
|
|
22
22
|
const path = require('path');
|
|
23
23
|
const { execSync } = require('child_process');
|
|
24
|
-
const { parseGovernance, flattenGates
|
|
24
|
+
const { parseGovernance, flattenGates } = require('../governance/parse');
|
|
25
|
+
const { detectBranchStrategy, countFeatureBranches } = require('../governance/drift-utils');
|
|
25
26
|
const { isModified, readFrontmatter } = require('../update/integrity');
|
|
27
|
+
const { detectWorkspace } = require('../workspace/detect');
|
|
28
|
+
const { enumerateMembers } = require('../workspace/enumerate');
|
|
26
29
|
const { EXIT_USER, EXIT_INTERNAL } = require('../cli-errors');
|
|
27
30
|
|
|
28
31
|
const GREEN = '\x1b[32m';
|
|
@@ -45,15 +48,36 @@ const ICON_FAIL = `${RED}✗${RESET}`;
|
|
|
45
48
|
* hooks, file presence). Useful in CI where .claude/ is
|
|
46
49
|
* either absent or freshly generated via `crag analyze`.
|
|
47
50
|
* --strict Treat warnings as failures (exit 1 on any warn).
|
|
51
|
+
* --workspace Run doctor on every workspace member that has `.claude/`,
|
|
52
|
+
* plus the root. Aggregates pass/warn/fail counts across
|
|
53
|
+
* members and exits non-zero if ANY member failed.
|
|
48
54
|
*/
|
|
49
55
|
function doctor(args) {
|
|
50
56
|
const cwd = process.cwd();
|
|
51
57
|
const jsonOutput = args.includes('--json');
|
|
52
58
|
const ciMode = args.includes('--ci');
|
|
53
59
|
const strict = args.includes('--strict');
|
|
60
|
+
const workspace = args.includes('--workspace');
|
|
54
61
|
|
|
55
|
-
|
|
62
|
+
// Workspace mode: run diagnostics on root + every member. Emits one
|
|
63
|
+
// combined report (JSON) or prints per-member sections (human).
|
|
64
|
+
if (workspace) {
|
|
65
|
+
const { reports, combined } = runWorkspaceDiagnostics(cwd, { ciMode });
|
|
66
|
+
const exitCode = computeExitCode(combined, strict);
|
|
67
|
+
|
|
68
|
+
if (jsonOutput) {
|
|
69
|
+
console.log(JSON.stringify({ mode: 'workspace', reports, combined }, null, 2));
|
|
70
|
+
process.exit(exitCode);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const r of reports) {
|
|
74
|
+
printReport(r, { ciMode, strict });
|
|
75
|
+
}
|
|
76
|
+
printWorkspaceSummary(combined);
|
|
77
|
+
process.exit(exitCode);
|
|
78
|
+
}
|
|
56
79
|
|
|
80
|
+
const report = runDiagnostics(cwd, { ciMode });
|
|
57
81
|
const exitCode = computeExitCode(report, strict);
|
|
58
82
|
|
|
59
83
|
if (jsonOutput) {
|
|
@@ -65,6 +89,45 @@ function doctor(args) {
|
|
|
65
89
|
process.exit(exitCode);
|
|
66
90
|
}
|
|
67
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Run doctor across a workspace.
|
|
94
|
+
*
|
|
95
|
+
* Always includes the root (cwd). Then, if the root is a workspace, runs
|
|
96
|
+
* against each enumerated member. Members without a `.claude/` directory
|
|
97
|
+
* are skipped — there's nothing for doctor to check in them.
|
|
98
|
+
*
|
|
99
|
+
* Returns { reports: Report[], combined: CombinedCounts }.
|
|
100
|
+
*/
|
|
101
|
+
function runWorkspaceDiagnostics(cwd, options = {}) {
|
|
102
|
+
const reports = [];
|
|
103
|
+
const rootReport = runDiagnostics(cwd, options);
|
|
104
|
+
reports.push(rootReport);
|
|
105
|
+
|
|
106
|
+
const ws = detectWorkspace(cwd);
|
|
107
|
+
if (ws.type !== 'none') {
|
|
108
|
+
const members = enumerateMembers(ws);
|
|
109
|
+
for (const m of members) {
|
|
110
|
+
if (!m.hasClaude) continue; // nothing for doctor to see in this member
|
|
111
|
+
const memberReport = runDiagnostics(m.path, options);
|
|
112
|
+
reports.push(memberReport);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const combined = reports.reduce(
|
|
117
|
+
(acc, r) => ({
|
|
118
|
+
cwd,
|
|
119
|
+
pass: acc.pass + r.pass,
|
|
120
|
+
warn: acc.warn + r.warn,
|
|
121
|
+
fail: acc.fail + r.fail,
|
|
122
|
+
memberCount: acc.memberCount + 1,
|
|
123
|
+
ciMode: r.ciMode,
|
|
124
|
+
}),
|
|
125
|
+
{ cwd, pass: 0, warn: 0, fail: 0, memberCount: 0, ciMode: options.ciMode || false }
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return { reports, combined };
|
|
129
|
+
}
|
|
130
|
+
|
|
68
131
|
function computeExitCode(report, strict) {
|
|
69
132
|
if (report.fail > 0) return 1;
|
|
70
133
|
if (strict && report.warn > 0) return 1;
|
|
@@ -381,68 +444,6 @@ function diagnoseHooks(cwd) {
|
|
|
381
444
|
// Section: Drift (reuses crag diff logic)
|
|
382
445
|
// ============================================================================
|
|
383
446
|
|
|
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
|
-
|
|
446
447
|
function diagnoseDrift(cwd) {
|
|
447
448
|
const checks = [];
|
|
448
449
|
const govPath = path.join(cwd, '.claude', 'governance.md');
|
|
@@ -644,4 +645,25 @@ function printReport(report, { ciMode = false, strict = false } = {}) {
|
|
|
644
645
|
}
|
|
645
646
|
}
|
|
646
647
|
|
|
647
|
-
|
|
648
|
+
/**
|
|
649
|
+
* Print a per-workspace summary after all individual reports have been
|
|
650
|
+
* printed. Shows the combined pass/warn/fail counts across the root and
|
|
651
|
+
* every member that was inspected.
|
|
652
|
+
*/
|
|
653
|
+
function printWorkspaceSummary(combined) {
|
|
654
|
+
const total = combined.pass + combined.warn + combined.fail;
|
|
655
|
+
const msg = ` Workspace total — ${combined.pass}/${total} pass, ${combined.warn} warn, ${combined.fail} fail (${combined.memberCount} target${combined.memberCount === 1 ? '' : 's'})`;
|
|
656
|
+
if (combined.fail > 0) console.log(`${RED}${BOLD}${msg}${RESET}\n`);
|
|
657
|
+
else if (combined.warn > 0) console.log(`${YELLOW}${BOLD}${msg}${RESET}\n`);
|
|
658
|
+
else console.log(`${GREEN}${BOLD}${msg}${RESET}\n`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
module.exports = {
|
|
662
|
+
doctor,
|
|
663
|
+
runDiagnostics,
|
|
664
|
+
runWorkspaceDiagnostics,
|
|
665
|
+
// Re-exported from drift-utils for backward compatibility with existing
|
|
666
|
+
// tests that import from './commands/doctor'.
|
|
667
|
+
countFeatureBranches,
|
|
668
|
+
detectBranchStrategy,
|
|
669
|
+
};
|
package/src/crag-agent.md
CHANGED
|
@@ -155,7 +155,7 @@ Create `.claude/hooks/`:
|
|
|
155
155
|
|
|
156
156
|
**auto-post-start.sh** — always generate. Gate enforcement safety net. Reads tool input from stdin, checks if the command is a `git commit`, warns if `.claude/.gates-passed` sentinel doesn't exist. Non-blocking (warns, doesn't prevent). Same for all projects.
|
|
157
157
|
|
|
158
|
-
**sandbox-guard.sh** — always generate. Security hardening. Reads tool input from stdin (PreToolUse on Bash), hard-blocks destructive system commands (rm -rf /, dd, mkfs, DROP TABLE, docker system prune, kubectl delete namespace, curl|bash, force-push to main). Warns on file operations targeting paths outside the project root. Same for all projects.
|
|
158
|
+
**sandbox-guard.sh** — always generate. Security hardening. Reads tool input from stdin (PreToolUse on Bash), hard-blocks destructive system commands (rm -rf /, dd, mkfs, DROP TABLE, docker system prune, kubectl delete namespace, curl|bash, force-push to main). Warns on file operations targeting paths outside the project root. Same for all projects. **MUST include `# rtk-hook-version: 3` as line 2 (right after the shebang)** — without this marker in the first 5 lines, RTK prints "Hook outdated" warnings on every invocation and `crag doctor` flags the hook as warn. Also **strip single-quoted strings before the destructive-pattern grep** (`CHECK=$(echo "$COMMAND" | sed "s/'[^']*'/''/g")`) so data payloads that legitimately mention destructive verbs inside quoted strings (e.g. `python script.py '{"content":"rm -rf / is blocked"}'`) don't false-trip the guard.
|
|
159
159
|
|
|
160
160
|
**pre-compact-snapshot.sh** — only if MemStack enabled. Use correct project name.
|
|
161
161
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Drift-detection helpers shared between `crag doctor` and `crag diff`.
|
|
5
|
+
*
|
|
6
|
+
* Both commands need to answer the same two questions:
|
|
7
|
+
*
|
|
8
|
+
* 1. What branch strategy does the governance claim? (text scan of the
|
|
9
|
+
* `## Branch Strategy` section — first keyword mention wins, so a
|
|
10
|
+
* workspace wrapper that says "trunk-based at root, feature branches
|
|
11
|
+
* in sub-repos" is classified as trunk-based, which matches its own
|
|
12
|
+
* git reality even when sub-repos use feature branches.)
|
|
13
|
+
*
|
|
14
|
+
* 2. What does git actually show? (count unique feature branches across
|
|
15
|
+
* local + remote refs, stripping `origin/` so a repo whose local
|
|
16
|
+
* branches are merged+deleted but whose remote still has open
|
|
17
|
+
* `feat/*` refs is correctly classified as feature-branches.)
|
|
18
|
+
*
|
|
19
|
+
* Before this module existed, doctor.js and diff.js each had their own
|
|
20
|
+
* (slightly different, slightly wrong) implementations. The duplication
|
|
21
|
+
* meant a fix landed in doctor went unapplied in diff and vice-versa —
|
|
22
|
+
* real bug, caught by stress testing against real repos.
|
|
23
|
+
*
|
|
24
|
+
* This module is the single source of truth. Import from here; do not
|
|
25
|
+
* reimplement.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const { extractSection } = require('./parse');
|
|
29
|
+
|
|
30
|
+
const FEATURE_PREFIXES = ['feat', 'fix', 'docs', 'chore', 'feature', 'hotfix'];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Detect the branch strategy declared in a governance.md document.
|
|
34
|
+
*
|
|
35
|
+
* Scopes the text scan to the `## Branch Strategy` section (avoiding false
|
|
36
|
+
* matches against unrelated prose). Within that section, the FIRST keyword
|
|
37
|
+
* to appear wins — this matches human reading order where the opening
|
|
38
|
+
* statement is the rule and later lines are qualifications.
|
|
39
|
+
*
|
|
40
|
+
* Returns 'feature-branches', 'trunk-based', or null.
|
|
41
|
+
*/
|
|
42
|
+
function detectBranchStrategy(content) {
|
|
43
|
+
if (typeof content !== 'string' || content.length === 0) return null;
|
|
44
|
+
const section = extractSection(content, 'Branch Strategy');
|
|
45
|
+
const scope = section || content; // fall back to whole file if section absent
|
|
46
|
+
const featureIdx = scope.search(/[Ff]eature branches/);
|
|
47
|
+
const trunkIdx = scope.search(/[Tt]runk-based/);
|
|
48
|
+
if (featureIdx === -1 && trunkIdx === -1) return null;
|
|
49
|
+
if (featureIdx === -1) return 'trunk-based';
|
|
50
|
+
if (trunkIdx === -1) return 'feature-branches';
|
|
51
|
+
return featureIdx < trunkIdx ? 'feature-branches' : 'trunk-based';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Given the raw output of `git branch -a --format="%(refname:short)"`, return
|
|
56
|
+
* the list of unique feature branches (by short name, after stripping any
|
|
57
|
+
* remote prefix).
|
|
58
|
+
*
|
|
59
|
+
* Normalizes remote-tracking refs so `origin/feat/foo` and `feat/foo`
|
|
60
|
+
* both count as the same feature branch. A repo whose local branches
|
|
61
|
+
* have been merged-and-deleted but whose remote still has feat/*
|
|
62
|
+
* branches IS still practicing feature-branch development — callers
|
|
63
|
+
* MUST NOT misread that as "trunk-based".
|
|
64
|
+
*
|
|
65
|
+
* Also skips symbolic refs like `origin/HEAD`.
|
|
66
|
+
*/
|
|
67
|
+
function countFeatureBranches(gitBranchOutput) {
|
|
68
|
+
if (typeof gitBranchOutput !== 'string' || gitBranchOutput.length === 0) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
const rawList = gitBranchOutput
|
|
72
|
+
.split('\n')
|
|
73
|
+
.map(b => b.trim())
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
.filter(b => !b.endsWith('/HEAD')); // skip symbolic refs
|
|
76
|
+
|
|
77
|
+
const prefixGroup = FEATURE_PREFIXES.join('|');
|
|
78
|
+
const remoteStripRe = new RegExp(`^[A-Za-z0-9_.-]+/((?:${prefixGroup})/.+)$`);
|
|
79
|
+
const featureRe = new RegExp(`^(${prefixGroup})/`);
|
|
80
|
+
|
|
81
|
+
const normalized = rawList.map(b => {
|
|
82
|
+
const m = b.match(remoteStripRe);
|
|
83
|
+
return m ? m[1] : b;
|
|
84
|
+
});
|
|
85
|
+
const unique = [...new Set(normalized)];
|
|
86
|
+
return unique.filter(b => featureRe.test(b));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Classify a repo as 'feature-branches' or 'trunk-based' based on the
|
|
91
|
+
* current `git branch -a --format=%(refname:short)` output.
|
|
92
|
+
*
|
|
93
|
+
* Threshold: 3+ unique feature branches indicates active feature-branch
|
|
94
|
+
* workflow. Fewer (or 0) indicates trunk-based — even if history shows
|
|
95
|
+
* some merge commits from old feat/* branches that have been deleted.
|
|
96
|
+
*/
|
|
97
|
+
function classifyGitBranchStrategy(gitBranchOutput) {
|
|
98
|
+
const features = countFeatureBranches(gitBranchOutput);
|
|
99
|
+
return features.length > 2 ? 'feature-branches' : 'trunk-based';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
FEATURE_PREFIXES,
|
|
104
|
+
detectBranchStrategy,
|
|
105
|
+
countFeatureBranches,
|
|
106
|
+
classifyGitBranchStrategy,
|
|
107
|
+
};
|
|
@@ -71,6 +71,47 @@ function extractRunCommands(content) {
|
|
|
71
71
|
* (extra gates) are easier to spot than false negatives (missing gates).
|
|
72
72
|
*/
|
|
73
73
|
function isGateCommand(cmd) {
|
|
74
|
+
if (typeof cmd !== 'string' || cmd.length === 0) return false;
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------
|
|
77
|
+
// Early excludes — NEVER gates, regardless of keyword substring matches.
|
|
78
|
+
//
|
|
79
|
+
// A workflow step like `echo "Install: npm install -g foo"` contains
|
|
80
|
+
// the substring "npm install" even though it's shell plumbing (emitting
|
|
81
|
+
// a string to stdout). The old isGateCommand relied entirely on positive
|
|
82
|
+
// regex matches and so flagged these as gates, which `crag diff` then
|
|
83
|
+
// reported as EXTRA — noisy false positives.
|
|
84
|
+
//
|
|
85
|
+
// This first pass rejects obvious non-gates: variable assignments,
|
|
86
|
+
// echoes, git plumbing, release-specific scripts, and GitHub Actions
|
|
87
|
+
// output-writing. If ANY of these match, the command is definitively
|
|
88
|
+
// not a gate regardless of what it mentions downstream.
|
|
89
|
+
// ---------------------------------------------------------------------
|
|
90
|
+
const excludePatterns = [
|
|
91
|
+
// Shell I/O plumbing
|
|
92
|
+
/^\s*echo(\s|$)/,
|
|
93
|
+
/^\s*printf(\s|$)/,
|
|
94
|
+
// Shell variable assignment: NAME=value or NAME=$(subshell)
|
|
95
|
+
/^\s*[A-Z_][A-Z0-9_]*=/,
|
|
96
|
+
// Git mutations and introspection (deploy/release, not gates)
|
|
97
|
+
/^\s*git\s+(config|push|pull|fetch|add|commit|tag|merge|rebase|reset|checkout|stash|clone|init|remote|log|status)\b/,
|
|
98
|
+
// npm release/distribution verbs — not gates
|
|
99
|
+
/^\s*npm\s+(publish|pack|view|audit|login|logout|adduser|deprecate|owner|team|whoami|access)\b/,
|
|
100
|
+
// Release scripts live under scripts/ and are NOT gates themselves
|
|
101
|
+
/^\s*node\s+scripts\/(bump-version|release|publish|sync-)/,
|
|
102
|
+
/^\s*npm\s+run\s+(release|publish|sync-|prepublish|postpublish|prepare)\b/,
|
|
103
|
+
// GitHub Actions output streams — these are CI plumbing, not gates
|
|
104
|
+
/\$GITHUB_(STEP_SUMMARY|OUTPUT|ENV|PATH)/,
|
|
105
|
+
// Conditional / control flow keywords on their own line
|
|
106
|
+
/^\s*(if|then|else|elif|fi|while|do|done|for|case|esac|break|continue|return)\s*$/,
|
|
107
|
+
/^\s*(if|for|while|case)\s+/,
|
|
108
|
+
// Filesystem setup that is not a gate
|
|
109
|
+
/^\s*(mkdir|rmdir|touch|ln|cp|mv|chmod|chown)\s/,
|
|
110
|
+
];
|
|
111
|
+
for (const rx of excludePatterns) {
|
|
112
|
+
if (rx.test(cmd)) return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
74
115
|
const patterns = [
|
|
75
116
|
// Node ecosystem
|
|
76
117
|
/\bnpm (run |ci|test|install)/,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: post-start-validation
|
|
3
|
-
version: 0.2.
|
|
3
|
+
version: 0.2.10
|
|
4
4
|
source_hash: 5a64dfe68b13577dff818fa63ddb6185be360c80b100f205bc586aac39e19e80
|
|
5
5
|
description: Universal validation and knowledge capture. Detects what changed, runs governance gates, captures knowledge, verifies deployment. Works for any project.
|
|
6
6
|
---
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pre-start-context
|
|
3
|
-
version: 0.2.
|
|
3
|
+
version: 0.2.10
|
|
4
4
|
source_hash: 4ace3388804f528a7e7a446466a506ca5f0da4c23e42b540b9ae8923eaf35e4e
|
|
5
5
|
description: Universal context loader. Discovers any project's stack, architecture, and state at runtime. Reads governance.md for project-specific rules. Works for any language, framework, or deployment target.
|
|
6
6
|
---
|