sourcebook 0.1.0 → 0.3.0

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.
@@ -226,5 +226,146 @@ export async function detectFrameworks(dir, files) {
226
226
  findings,
227
227
  });
228
228
  }
229
+ // --- Python ---
230
+ const hasPyproject = fs.existsSync(path.join(dir, "pyproject.toml"));
231
+ const hasRequirements = fs.existsSync(path.join(dir, "requirements.txt"));
232
+ const hasSetupPy = fs.existsSync(path.join(dir, "setup.py"));
233
+ if (hasPyproject || hasRequirements || hasSetupPy) {
234
+ const findings = [];
235
+ let pyDeps = "";
236
+ if (hasPyproject) {
237
+ try {
238
+ pyDeps = fs.readFileSync(path.join(dir, "pyproject.toml"), "utf-8");
239
+ }
240
+ catch { }
241
+ }
242
+ else if (hasRequirements) {
243
+ try {
244
+ pyDeps = fs.readFileSync(path.join(dir, "requirements.txt"), "utf-8");
245
+ }
246
+ catch { }
247
+ }
248
+ const pyDepsLower = pyDeps.toLowerCase();
249
+ // Django
250
+ if (pyDepsLower.includes("django")) {
251
+ const hasManagePy = files.includes("manage.py");
252
+ const settingsFile = files.find((f) => f.endsWith("settings.py") || f.includes("settings/"));
253
+ findings.push({
254
+ category: "Django",
255
+ description: `Django project${settingsFile ? ` (settings: ${settingsFile})` : ""}. Use \`python manage.py\` for management commands.`,
256
+ confidence: "high",
257
+ discoverable: false,
258
+ });
259
+ if (files.some((f) => f.endsWith("models.py"))) {
260
+ findings.push({
261
+ category: "Django",
262
+ description: "After modifying models, run `python manage.py makemigrations && python manage.py migrate`.",
263
+ rationale: "Agents forget to create migrations after model changes, causing runtime errors.",
264
+ confidence: "high",
265
+ discoverable: false,
266
+ });
267
+ }
268
+ detected.push({ name: "Django", findings });
269
+ }
270
+ // FastAPI
271
+ else if (pyDepsLower.includes("fastapi")) {
272
+ findings.push({
273
+ category: "FastAPI",
274
+ description: "FastAPI project. Use Pydantic models for request/response schemas, not raw dicts.",
275
+ confidence: "high",
276
+ discoverable: false,
277
+ });
278
+ if (pyDepsLower.includes("sqlalchemy") || pyDepsLower.includes("sqlmodel")) {
279
+ findings.push({
280
+ category: "FastAPI",
281
+ description: "Uses SQLAlchemy/SQLModel for ORM. Database sessions must be properly closed (use dependency injection).",
282
+ confidence: "high",
283
+ discoverable: false,
284
+ });
285
+ }
286
+ detected.push({ name: "FastAPI", findings });
287
+ }
288
+ // Flask
289
+ else if (pyDepsLower.includes("flask")) {
290
+ detected.push({ name: "Flask", findings: [] });
291
+ }
292
+ // Generic Python
293
+ else {
294
+ detected.push({ name: "Python", findings: [] });
295
+ }
296
+ // pytest detection
297
+ if (pyDepsLower.includes("pytest")) {
298
+ findings.push({
299
+ category: "Testing",
300
+ description: "Uses pytest. Test files should be named `test_*.py` or `*_test.py`.",
301
+ confidence: "high",
302
+ discoverable: false,
303
+ });
304
+ }
305
+ // Virtual environment detection
306
+ const hasVenv = files.some((f) => f.startsWith(".venv/") || f.startsWith("venv/"));
307
+ if (hasVenv) {
308
+ findings.push({
309
+ category: "Python environment",
310
+ description: "Virtual environment detected. Activate with `source .venv/bin/activate` before running commands.",
311
+ confidence: "medium",
312
+ discoverable: false,
313
+ });
314
+ }
315
+ }
316
+ // --- Go ---
317
+ const hasGoMod = fs.existsSync(path.join(dir, "go.mod"));
318
+ if (hasGoMod) {
319
+ const findings = [];
320
+ try {
321
+ const goMod = fs.readFileSync(path.join(dir, "go.mod"), "utf-8");
322
+ const moduleMatch = goMod.match(/^module\s+(.+)$/m);
323
+ if (moduleMatch) {
324
+ findings.push({
325
+ category: "Go module",
326
+ description: `Module path: ${moduleMatch[1]}. Use this as the import prefix for all internal packages.`,
327
+ confidence: "high",
328
+ discoverable: false,
329
+ });
330
+ }
331
+ // Detect web frameworks
332
+ if (goMod.includes("github.com/gin-gonic/gin")) {
333
+ detected.push({ name: "Go + Gin", findings });
334
+ }
335
+ else if (goMod.includes("github.com/labstack/echo")) {
336
+ detected.push({ name: "Go + Echo", findings });
337
+ }
338
+ else if (goMod.includes("github.com/gofiber/fiber")) {
339
+ detected.push({ name: "Go + Fiber", findings });
340
+ }
341
+ else {
342
+ detected.push({ name: "Go", findings });
343
+ }
344
+ }
345
+ catch {
346
+ detected.push({ name: "Go", findings: [] });
347
+ }
348
+ // cmd/ vs pkg/ layout
349
+ const hasCmdDir = files.some((f) => f.startsWith("cmd/"));
350
+ const hasPkgDir = files.some((f) => f.startsWith("pkg/"));
351
+ const hasInternalDir = files.some((f) => f.startsWith("internal/"));
352
+ if (hasCmdDir) {
353
+ findings.push({
354
+ category: "Go layout",
355
+ description: `Standard Go project layout: ${hasCmdDir ? "cmd/" : ""}${hasPkgDir ? " pkg/" : ""}${hasInternalDir ? " internal/" : ""}. Entry points are in cmd/ subdirectories.`,
356
+ confidence: "high",
357
+ discoverable: false,
358
+ });
359
+ }
360
+ if (hasInternalDir) {
361
+ findings.push({
362
+ category: "Go visibility",
363
+ description: "internal/ packages cannot be imported by external modules. Keep private code here.",
364
+ rationale: "Go enforces this at the compiler level. Agents may try to import internal packages from external code.",
365
+ confidence: "high",
366
+ discoverable: false,
367
+ });
368
+ }
369
+ }
229
370
  return detected;
230
371
  }
@@ -19,6 +19,8 @@ export async function analyzeGitHistory(dir) {
19
19
  }
20
20
  // 1. Reverted commits -- "don't do this" signals
21
21
  findings.push(...detectRevertedPatterns(dir, revertedPatterns));
22
+ // 1b. Anti-patterns from reverts and deleted approaches
23
+ findings.push(...detectAntiPatterns(dir));
22
24
  // 2. Recently active areas
23
25
  findings.push(...detectActiveAreas(dir, activeAreas));
24
26
  // 3. Co-change coupling -- invisible dependencies
@@ -84,6 +86,73 @@ function detectRevertedPatterns(dir, revertedPatterns) {
84
86
  }
85
87
  return findings;
86
88
  }
89
+ /**
90
+ * Detect anti-patterns from reverted commits and deleted files.
91
+ * Reverts contain the original commit message — extract the approach that failed.
92
+ * Deleted files may indicate abandoned approaches.
93
+ */
94
+ function detectAntiPatterns(dir) {
95
+ const findings = [];
96
+ // Extract detailed info from reverted commits
97
+ const revertLog = git(dir, 'log --grep="^Revert" --format="%s" --since="1 year ago" -20');
98
+ if (revertLog.trim()) {
99
+ const antiPatterns = [];
100
+ for (const line of revertLog.trim().split("\n").filter(Boolean)) {
101
+ const match = line.match(/^Revert "(.+)"/);
102
+ if (match) {
103
+ antiPatterns.push(match[1]);
104
+ }
105
+ }
106
+ if (antiPatterns.length > 0) {
107
+ for (const pattern of antiPatterns.slice(0, 5)) {
108
+ findings.push({
109
+ category: "Anti-patterns",
110
+ description: `Tried and reverted: "${pattern}". This approach was explicitly rejected.`,
111
+ rationale: "This commit was made and then reverted. The approach failed for a reason. Do not re-attempt without understanding why it was rolled back.",
112
+ confidence: "high",
113
+ discoverable: false,
114
+ });
115
+ }
116
+ }
117
+ }
118
+ // Detect files deleted in bulk (abandoned features/approaches)
119
+ const deletedLog = git(dir, 'log --diff-filter=D --name-only --pretty=format:"COMMIT %s" --since="6 months ago" -50');
120
+ if (deletedLog.trim()) {
121
+ const deletionBatches = [];
122
+ let currentMessage = "";
123
+ let currentFiles = [];
124
+ for (const line of deletedLog.split("\n")) {
125
+ const commitMatch = line.match(/^"?COMMIT (.+)"?$/);
126
+ if (commitMatch) {
127
+ if (currentFiles.length >= 3) {
128
+ deletionBatches.push({ message: currentMessage, files: currentFiles });
129
+ }
130
+ currentMessage = commitMatch[1];
131
+ currentFiles = [];
132
+ }
133
+ else if (line.trim() && !line.includes("node_modules")) {
134
+ currentFiles.push(line.trim());
135
+ }
136
+ }
137
+ if (currentFiles.length >= 3) {
138
+ deletionBatches.push({ message: currentMessage, files: currentFiles });
139
+ }
140
+ // Only report significant deletions (3+ files in one commit = abandoned feature)
141
+ for (const batch of deletionBatches.slice(0, 3)) {
142
+ if (batch.files.length >= 3) {
143
+ const fileList = batch.files.slice(0, 3).map((f) => path.basename(f)).join(", ");
144
+ findings.push({
145
+ category: "Anti-patterns",
146
+ description: `Abandoned: "${batch.message}" (${batch.files.length} files deleted including ${fileList})`,
147
+ rationale: "A batch of files was deleted in a single commit, suggesting an abandoned approach or feature removal.",
148
+ confidence: "medium",
149
+ discoverable: false,
150
+ });
151
+ }
152
+ }
153
+ }
154
+ return findings;
155
+ }
87
156
  /**
88
157
  * Find recently active areas -- where development is concentrated.
89
158
  */
@@ -18,6 +18,8 @@ const IGNORE_PATTERNS = [
18
18
  "**/ios/**",
19
19
  "**/*.lock",
20
20
  "**/package-lock.json",
21
+ "**/.claude/worktrees/**",
22
+ "**/.claude/**",
21
23
  ];
22
24
  export async function scanProject(dir) {
23
25
  // Collect all files
@@ -6,11 +6,13 @@ import path from "node:path";
6
6
  */
7
7
  export async function detectPatterns(dir, files, frameworks) {
8
8
  const findings = [];
9
- // Only analyze source files
9
+ // Analyze source files (JS/TS + Python + Go)
10
10
  const sourceFiles = files.filter((f) => f.endsWith(".ts") ||
11
11
  f.endsWith(".tsx") ||
12
12
  f.endsWith(".js") ||
13
- f.endsWith(".jsx"));
13
+ f.endsWith(".jsx") ||
14
+ f.endsWith(".py") ||
15
+ f.endsWith(".go"));
14
16
  // Sample files for pattern detection (don't read everything)
15
17
  const sampled = sampleFiles(sourceFiles, 50);
16
18
  const fileContents = new Map();
@@ -33,6 +35,10 @@ export async function detectPatterns(dir, files, frameworks) {
33
35
  findings.push(...detectErrorHandling(fileContents));
34
36
  // --- Export patterns ---
35
37
  findings.push(...detectExportPatterns(fileContents));
38
+ // --- Python conventions ---
39
+ findings.push(...detectPythonConventions(files, fileContents));
40
+ // --- Go conventions ---
41
+ findings.push(...detectGoConventions(files, fileContents));
36
42
  // Filter out discoverable findings
37
43
  return findings.filter((f) => !f.discoverable);
38
44
  }
@@ -170,6 +176,85 @@ function detectErrorHandling(contents) {
170
176
  }
171
177
  return findings;
172
178
  }
179
+ function detectPythonConventions(files, contents) {
180
+ const findings = [];
181
+ const pyFiles = [...contents.entries()].filter(([f]) => f.endsWith(".py"));
182
+ if (pyFiles.length < 3)
183
+ return findings;
184
+ // Detect __init__.py barrel pattern
185
+ const initFiles = files.filter((f) => f.endsWith("__init__.py"));
186
+ const nonEmptyInits = initFiles.filter((f) => {
187
+ const content = contents.get(f);
188
+ return content && content.trim().length > 10;
189
+ });
190
+ if (nonEmptyInits.length >= 3) {
191
+ findings.push({
192
+ category: "Python conventions",
193
+ description: "Uses __init__.py as barrel exports. Import from the package, not from internal modules.",
194
+ evidence: `${nonEmptyInits.length} non-empty __init__.py files`,
195
+ confidence: "high",
196
+ discoverable: false,
197
+ });
198
+ }
199
+ // Detect type hint usage
200
+ let typeHintCount = 0;
201
+ let noHintCount = 0;
202
+ for (const [, content] of pyFiles) {
203
+ const funcDefs = content.match(/def\s+\w+\s*\([^)]*\)/g) || [];
204
+ for (const def of funcDefs) {
205
+ if (def.includes(":") && def.includes("->"))
206
+ typeHintCount++;
207
+ else
208
+ noHintCount++;
209
+ }
210
+ }
211
+ if (typeHintCount + noHintCount > 10 && typeHintCount > noHintCount * 2) {
212
+ findings.push({
213
+ category: "Python conventions",
214
+ description: "Project uses type hints extensively. Add type annotations to all new functions.",
215
+ evidence: `${typeHintCount} typed vs ${noHintCount} untyped function signatures`,
216
+ confidence: "high",
217
+ discoverable: false,
218
+ });
219
+ }
220
+ return findings;
221
+ }
222
+ function detectGoConventions(files, contents) {
223
+ const findings = [];
224
+ const goFiles = [...contents.entries()].filter(([f]) => f.endsWith(".go"));
225
+ if (goFiles.length < 3)
226
+ return findings;
227
+ // Detect error handling style
228
+ let errNilCount = 0;
229
+ let errWrapCount = 0;
230
+ for (const [, content] of goFiles) {
231
+ errNilCount += (content.match(/if\s+err\s*!=\s*nil/g) || []).length;
232
+ errWrapCount += (content.match(/fmt\.Errorf\(.*%w/g) || []).length;
233
+ }
234
+ if (errWrapCount > 5 && errWrapCount > errNilCount * 0.3) {
235
+ findings.push({
236
+ category: "Go conventions",
237
+ description: "Project wraps errors with fmt.Errorf(%w). Use error wrapping, not bare returns.",
238
+ evidence: `${errWrapCount} wrapped errors found`,
239
+ confidence: "high",
240
+ discoverable: false,
241
+ });
242
+ }
243
+ // Detect interface-first design
244
+ let interfaceCount = 0;
245
+ for (const [, content] of goFiles) {
246
+ interfaceCount += (content.match(/type\s+\w+\s+interface\s*\{/g) || []).length;
247
+ }
248
+ if (interfaceCount >= 5) {
249
+ findings.push({
250
+ category: "Go conventions",
251
+ description: `Project uses interface-first design (${interfaceCount} interfaces). Define interfaces at the consumer, not the producer.`,
252
+ confidence: "medium",
253
+ discoverable: false,
254
+ });
255
+ }
256
+ return findings;
257
+ }
173
258
  function detectExportPatterns(contents) {
174
259
  const findings = [];
175
260
  let defaultExports = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sourcebook",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Extract the conventions, constraints, and architectural truths your AI coding agents keep missing.",
5
5
  "type": "module",
6
6
  "bin": {