@whitehatd/crag 0.0.1 → 0.2.1
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/README.md +838 -15
- package/bin/crag.js +7 -0
- package/package.json +18 -4
- package/src/cli.js +102 -0
- package/src/commands/analyze.js +513 -0
- package/src/commands/check.js +55 -0
- package/src/commands/compile.js +104 -0
- package/src/commands/diff.js +289 -0
- package/src/commands/init.js +112 -0
- package/src/commands/upgrade.js +64 -0
- package/src/commands/workspace.js +94 -0
- package/src/compile/agents-md.js +58 -0
- package/src/compile/atomic-write.js +32 -0
- package/src/compile/cline.js +83 -0
- package/src/compile/cody.js +82 -0
- package/src/compile/continue.js +78 -0
- package/src/compile/copilot.js +70 -0
- package/src/compile/cursor-rules.js +66 -0
- package/src/compile/gemini-md.js +58 -0
- package/src/compile/github-actions.js +165 -0
- package/src/compile/husky.js +66 -0
- package/src/compile/pre-commit.js +50 -0
- package/src/compile/windsurf.js +76 -0
- package/src/compile/zed.js +86 -0
- package/src/crag-agent.md +254 -0
- package/src/governance/gate-to-shell.js +28 -0
- package/src/governance/parse.js +182 -0
- package/src/skills/post-start-validation.md +297 -0
- package/src/skills/pre-start-context.md +506 -0
- package/src/update/integrity.js +131 -0
- package/src/update/skill-sync.js +116 -0
- package/src/update/version-check.js +156 -0
- package/src/workspace/detect.js +190 -0
- package/src/workspace/enumerate.js +270 -0
- package/src/workspace/governance.js +119 -0
- package/cli.js +0 -15
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve workspace members from detected workspace.
|
|
8
|
+
* Expands glob patterns to actual directories and enriches with metadata.
|
|
9
|
+
*/
|
|
10
|
+
function enumerateMembers(workspace) {
|
|
11
|
+
const members = [];
|
|
12
|
+
|
|
13
|
+
switch (workspace.type) {
|
|
14
|
+
case 'pnpm':
|
|
15
|
+
members.push(...resolvePnpmMembers(workspace));
|
|
16
|
+
break;
|
|
17
|
+
case 'npm':
|
|
18
|
+
case 'yarn':
|
|
19
|
+
case 'turbo':
|
|
20
|
+
members.push(...resolveGlobMembers(workspace));
|
|
21
|
+
break;
|
|
22
|
+
case 'cargo':
|
|
23
|
+
case 'go':
|
|
24
|
+
members.push(...resolveGlobMembers(workspace));
|
|
25
|
+
break;
|
|
26
|
+
case 'gradle':
|
|
27
|
+
case 'maven':
|
|
28
|
+
members.push(...resolveExplicitMembers(workspace));
|
|
29
|
+
break;
|
|
30
|
+
case 'nx':
|
|
31
|
+
members.push(...resolveNxMembers(workspace));
|
|
32
|
+
break;
|
|
33
|
+
case 'bazel':
|
|
34
|
+
members.push(...resolveBazelMembers(workspace));
|
|
35
|
+
break;
|
|
36
|
+
case 'git-submodules':
|
|
37
|
+
members.push(...resolveSubmodules(workspace));
|
|
38
|
+
break;
|
|
39
|
+
case 'independent-repos':
|
|
40
|
+
members.push(...resolveNestedRepos(workspace));
|
|
41
|
+
break;
|
|
42
|
+
default:
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Enrich each member with metadata
|
|
47
|
+
return members.map(m => enrichMember(workspace.root, m));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve pnpm workspace members from pnpm-workspace.yaml.
|
|
52
|
+
*/
|
|
53
|
+
function resolvePnpmMembers(workspace) {
|
|
54
|
+
try {
|
|
55
|
+
const content = fs.readFileSync(path.join(workspace.root, 'pnpm-workspace.yaml'), 'utf-8');
|
|
56
|
+
const patterns = [];
|
|
57
|
+
let inPackages = false;
|
|
58
|
+
|
|
59
|
+
for (const line of content.split('\n')) {
|
|
60
|
+
if (/^packages:/.test(line)) { inPackages = true; continue; }
|
|
61
|
+
if (inPackages && /^\s+-\s+/.test(line)) {
|
|
62
|
+
const pattern = line.replace(/^\s+-\s+/, '').replace(/['"]/g, '').trim();
|
|
63
|
+
if (pattern) patterns.push(pattern);
|
|
64
|
+
} else if (inPackages && /^\S/.test(line)) {
|
|
65
|
+
inPackages = false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return expandGlobs(workspace.root, patterns);
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve members from workspace patterns (npm, yarn, cargo, go).
|
|
77
|
+
*/
|
|
78
|
+
function resolveGlobMembers(workspace) {
|
|
79
|
+
const patterns = workspace.patterns || [];
|
|
80
|
+
return expandGlobs(workspace.root, patterns);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve explicitly listed members (gradle, maven).
|
|
85
|
+
*/
|
|
86
|
+
function resolveExplicitMembers(workspace) {
|
|
87
|
+
const patterns = workspace.patterns || [];
|
|
88
|
+
return patterns
|
|
89
|
+
.map(p => {
|
|
90
|
+
const fullPath = path.join(workspace.root, p);
|
|
91
|
+
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
|
|
92
|
+
return { name: path.basename(p), path: fullPath };
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
})
|
|
96
|
+
.filter(Boolean);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve Nx workspace members.
|
|
101
|
+
*/
|
|
102
|
+
function resolveNxMembers(workspace) {
|
|
103
|
+
// Nx uses project.json or package.json in subdirectories
|
|
104
|
+
// Try common patterns: apps/*, packages/*, libs/*
|
|
105
|
+
return expandGlobs(workspace.root, ['apps/*', 'packages/*', 'libs/*']);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve Bazel workspace members by finding BUILD files.
|
|
110
|
+
*/
|
|
111
|
+
function resolveBazelMembers(workspace) {
|
|
112
|
+
const members = [];
|
|
113
|
+
try {
|
|
114
|
+
const entries = fs.readdirSync(workspace.root, { withFileTypes: true });
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
117
|
+
const dir = path.join(workspace.root, entry.name);
|
|
118
|
+
if (fs.existsSync(path.join(dir, 'BUILD')) || fs.existsSync(path.join(dir, 'BUILD.bazel'))) {
|
|
119
|
+
members.push({ name: entry.name, path: dir });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch { /* skip */ }
|
|
123
|
+
return members;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve git submodule members.
|
|
128
|
+
*/
|
|
129
|
+
function resolveSubmodules(workspace) {
|
|
130
|
+
return (workspace.submodules || []).map(sm => ({
|
|
131
|
+
name: sm.name,
|
|
132
|
+
path: path.join(workspace.root, sm.path),
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resolve independent nested repo members.
|
|
138
|
+
*/
|
|
139
|
+
function resolveNestedRepos(workspace) {
|
|
140
|
+
return (workspace.nestedRepos || []).map(nr => ({
|
|
141
|
+
name: nr.name,
|
|
142
|
+
path: nr.path,
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Check that candidate path is contained within root.
|
|
148
|
+
* Uses real-path resolution to defeat symlink escape attacks.
|
|
149
|
+
*/
|
|
150
|
+
function isWithinRoot(root, candidate) {
|
|
151
|
+
try {
|
|
152
|
+
const realRoot = fs.realpathSync(root);
|
|
153
|
+
const realCandidate = fs.realpathSync(candidate);
|
|
154
|
+
const rel = path.relative(realRoot, realCandidate);
|
|
155
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
156
|
+
} catch {
|
|
157
|
+
// If realpath fails (e.g., broken symlink), reject
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Build a set of negation patterns for filtering.
|
|
164
|
+
*/
|
|
165
|
+
function buildNegations(patterns) {
|
|
166
|
+
const negs = [];
|
|
167
|
+
for (const p of patterns) {
|
|
168
|
+
if (p.startsWith('!')) {
|
|
169
|
+
// Strip leading ! and trailing /*, /**
|
|
170
|
+
const cleaned = p.slice(1).replace(/\/?\*\*?$/, '');
|
|
171
|
+
if (cleaned) negs.push(cleaned);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return negs;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Expand simple glob patterns (dir/*, dir/**) to actual directories.
|
|
179
|
+
* Only supports single-level wildcards (good enough for workspace configs).
|
|
180
|
+
* Validates all resolved paths stay within the workspace root (no traversal via
|
|
181
|
+
* .. or symlink escape).
|
|
182
|
+
*/
|
|
183
|
+
function expandGlobs(root, patterns) {
|
|
184
|
+
const members = [];
|
|
185
|
+
const seen = new Set();
|
|
186
|
+
const negations = buildNegations(patterns).map(n => n.replace(/\\/g, '/'));
|
|
187
|
+
|
|
188
|
+
// Normalize relative paths to forward slashes so comparisons work on Windows
|
|
189
|
+
const normalize = (p) => p.replace(/\\/g, '/');
|
|
190
|
+
const isNegated = (relPath) => {
|
|
191
|
+
const norm = normalize(relPath);
|
|
192
|
+
return negations.some(neg => norm === neg || norm.startsWith(neg + '/'));
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
for (const pattern of patterns) {
|
|
196
|
+
if (pattern.startsWith('!')) continue; // handled above
|
|
197
|
+
// Reject patterns containing parent traversal
|
|
198
|
+
if (pattern.includes('..')) continue;
|
|
199
|
+
|
|
200
|
+
// Simple wildcard expansion: "packages/*" → list dirs in packages/
|
|
201
|
+
const cleaned = pattern.replace(/\/?\*\*?$/, '');
|
|
202
|
+
const searchDir = path.join(root, cleaned || '.');
|
|
203
|
+
|
|
204
|
+
if (!fs.existsSync(searchDir)) continue;
|
|
205
|
+
if (!isWithinRoot(root, searchDir)) continue;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
if (cleaned && !pattern.includes('*')) {
|
|
209
|
+
// Exact path, not a glob
|
|
210
|
+
if (fs.statSync(searchDir).isDirectory() && !seen.has(searchDir)) {
|
|
211
|
+
const rel = path.relative(root, searchDir);
|
|
212
|
+
if (!isNegated(rel)) {
|
|
213
|
+
seen.add(searchDir);
|
|
214
|
+
members.push({ name: path.basename(cleaned), path: searchDir });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const entries = fs.readdirSync(searchDir, { withFileTypes: true });
|
|
221
|
+
for (const entry of entries) {
|
|
222
|
+
if (!entry.isDirectory()) continue;
|
|
223
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
224
|
+
const fullPath = path.join(searchDir, entry.name);
|
|
225
|
+
if (!isWithinRoot(root, fullPath)) continue;
|
|
226
|
+
const rel = path.relative(root, fullPath);
|
|
227
|
+
if (isNegated(rel)) continue;
|
|
228
|
+
if (!seen.has(fullPath)) {
|
|
229
|
+
seen.add(fullPath);
|
|
230
|
+
members.push({ name: entry.name, path: fullPath });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} catch { /* permission error — skip */ }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return members;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Enrich a member with metadata about its stack and governance.
|
|
241
|
+
*/
|
|
242
|
+
function enrichMember(workspaceRoot, member) {
|
|
243
|
+
const memberPath = member.path;
|
|
244
|
+
return {
|
|
245
|
+
name: member.name,
|
|
246
|
+
path: memberPath,
|
|
247
|
+
relativePath: path.relative(workspaceRoot, memberPath),
|
|
248
|
+
hasGovernance: fs.existsSync(path.join(memberPath, '.claude', 'governance.md')),
|
|
249
|
+
hasClaude: fs.existsSync(path.join(memberPath, '.claude')),
|
|
250
|
+
hasGit: fs.existsSync(path.join(memberPath, '.git')),
|
|
251
|
+
stack: detectMemberStack(memberPath),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Quick stack detection for a member directory.
|
|
257
|
+
*/
|
|
258
|
+
function detectMemberStack(dir) {
|
|
259
|
+
const stack = [];
|
|
260
|
+
if (fs.existsSync(path.join(dir, 'package.json'))) stack.push('node');
|
|
261
|
+
if (fs.existsSync(path.join(dir, 'Cargo.toml'))) stack.push('rust');
|
|
262
|
+
if (fs.existsSync(path.join(dir, 'go.mod'))) stack.push('go');
|
|
263
|
+
if (fs.existsSync(path.join(dir, 'pyproject.toml')) || fs.existsSync(path.join(dir, 'setup.py'))) stack.push('python');
|
|
264
|
+
if (fs.existsSync(path.join(dir, 'build.gradle.kts')) || fs.existsSync(path.join(dir, 'build.gradle'))) stack.push('java');
|
|
265
|
+
if (fs.existsSync(path.join(dir, 'pom.xml'))) stack.push('java');
|
|
266
|
+
if (fs.existsSync(path.join(dir, 'Dockerfile'))) stack.push('docker');
|
|
267
|
+
return stack;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = { enumerateMembers, expandGlobs, enrichMember };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { parseGovernance } = require('../governance/parse');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Load governance hierarchy for a workspace.
|
|
9
|
+
* Returns { root: parsed, members: { name: parsed } }
|
|
10
|
+
*/
|
|
11
|
+
function loadGovernanceHierarchy(workspace, members) {
|
|
12
|
+
const rootGovPath = path.join(workspace.root, '.claude', 'governance.md');
|
|
13
|
+
const root = fs.existsSync(rootGovPath)
|
|
14
|
+
? parseGovernance(fs.readFileSync(rootGovPath, 'utf-8'))
|
|
15
|
+
: null;
|
|
16
|
+
|
|
17
|
+
const memberGovs = {};
|
|
18
|
+
for (const member of members) {
|
|
19
|
+
if (member.hasGovernance) {
|
|
20
|
+
const govPath = path.join(member.path, '.claude', 'governance.md');
|
|
21
|
+
try {
|
|
22
|
+
memberGovs[member.name] = parseGovernance(fs.readFileSync(govPath, 'utf-8'));
|
|
23
|
+
} catch { /* skip unreadable */ }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { root, members: memberGovs };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Normalize a parsed governance object so merging is safe.
|
|
32
|
+
* Returns an object with guaranteed shape: { name, description, gates, runtimes, inherit }.
|
|
33
|
+
*/
|
|
34
|
+
function normalizeGovernance(g) {
|
|
35
|
+
if (!g || typeof g !== 'object') {
|
|
36
|
+
return { name: '', description: '', gates: {}, runtimes: [], inherit: null };
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
name: typeof g.name === 'string' ? g.name : '',
|
|
40
|
+
description: typeof g.description === 'string' ? g.description : '',
|
|
41
|
+
gates: (g.gates && typeof g.gates === 'object') ? g.gates : {},
|
|
42
|
+
runtimes: Array.isArray(g.runtimes) ? g.runtimes : [],
|
|
43
|
+
inherit: g.inherit || null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Safely clone a gates section, coercing malformed input to empty arrays.
|
|
49
|
+
*/
|
|
50
|
+
function cloneSection(data) {
|
|
51
|
+
if (!data || typeof data !== 'object') {
|
|
52
|
+
return { commands: [], path: null, condition: null };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
commands: Array.isArray(data.commands)
|
|
56
|
+
? data.commands.filter(c => c && typeof c.cmd === 'string').map(c => ({
|
|
57
|
+
cmd: c.cmd,
|
|
58
|
+
classification: c.classification || 'MANDATORY',
|
|
59
|
+
}))
|
|
60
|
+
: [],
|
|
61
|
+
path: typeof data.path === 'string' ? data.path : null,
|
|
62
|
+
condition: typeof data.condition === 'string' ? data.condition : null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Merge root governance with member governance.
|
|
68
|
+
* Root gates are mandatory (prepended with `root:` prefix).
|
|
69
|
+
* Member gates are additive (appended, replace on section name collision).
|
|
70
|
+
* Security and branch strategy from root cascade down.
|
|
71
|
+
*
|
|
72
|
+
* Both inputs are normalized before merging, so malformed input never throws.
|
|
73
|
+
*/
|
|
74
|
+
function mergeGovernance(rootParsed, memberParsed) {
|
|
75
|
+
const root = normalizeGovernance(rootParsed);
|
|
76
|
+
const member = normalizeGovernance(memberParsed);
|
|
77
|
+
|
|
78
|
+
// If root is empty (no name + no gates), just return member
|
|
79
|
+
if (!root.name && Object.keys(root.gates).length === 0) return member;
|
|
80
|
+
// If member is empty, return root
|
|
81
|
+
if (!member.name && Object.keys(member.gates).length === 0) return root;
|
|
82
|
+
|
|
83
|
+
const merged = {
|
|
84
|
+
name: member.name || root.name,
|
|
85
|
+
description: member.description || root.description,
|
|
86
|
+
gates: {},
|
|
87
|
+
runtimes: [...new Set([...root.runtimes, ...member.runtimes])],
|
|
88
|
+
inherit: member.inherit,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Root gates first (mandatory, prefixed to avoid collision)
|
|
92
|
+
for (const [section, data] of Object.entries(root.gates)) {
|
|
93
|
+
merged.gates[`root:${section}`] = cloneSection(data);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Member gates after (additive)
|
|
97
|
+
for (const [section, data] of Object.entries(member.gates)) {
|
|
98
|
+
merged.gates[section] = cloneSection(data);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return merged;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get effective governance for a specific member.
|
|
106
|
+
* If member has governance and it says inherit: root, merge.
|
|
107
|
+
* If member has governance without inherit, use member only.
|
|
108
|
+
* If member has no governance, use root.
|
|
109
|
+
*/
|
|
110
|
+
function getEffectiveGovernance(hierarchy, memberName) {
|
|
111
|
+
const memberGov = hierarchy.members[memberName];
|
|
112
|
+
const rootGov = hierarchy.root;
|
|
113
|
+
|
|
114
|
+
if (!memberGov) return rootGov;
|
|
115
|
+
if (memberGov.inherit === 'root') return mergeGovernance(rootGov, memberGov);
|
|
116
|
+
return memberGov;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { loadGovernanceHierarchy, mergeGovernance, getEffectiveGovernance, normalizeGovernance };
|
package/cli.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
'use strict';
|
|
4
|
-
|
|
5
|
-
console.log(`
|
|
6
|
-
crag — the bedrock layer for AI coding agents
|
|
7
|
-
|
|
8
|
-
This is a name-reservation placeholder. The full tool is in active development.
|
|
9
|
-
|
|
10
|
-
Visit: https://github.com/WhitehatD/crag
|
|
11
|
-
Follow: https://github.com/WhitehatD
|
|
12
|
-
|
|
13
|
-
When the full release ships, run:
|
|
14
|
-
npm install -g crag@latest
|
|
15
|
-
`);
|