sourcebook 0.1.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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Generate Cursor rules from scan results.
3
+ *
4
+ * Cursor deprecated `.cursorrules` in favor of modular `.cursor/rules/*.mdc` files.
5
+ * Each .mdc file has YAML frontmatter (description, globs, alwaysApply) + markdown body.
6
+ *
7
+ * We generate a single `sourcebook.mdc` with alwaysApply: true containing
8
+ * the same non-discoverable findings as the Claude generator, formatted for
9
+ * Cursor's conventions (shorter, more directive).
10
+ */
11
+ export function generateCursor(scan, budget) {
12
+ const critical = scan.findings.filter((f) => f.confidence === "high" && isCritical(f));
13
+ const important = scan.findings.filter((f) => f.confidence === "high" && !isCritical(f));
14
+ const supplementary = scan.findings.filter((f) => f.confidence === "medium");
15
+ const sections = [];
16
+ // MDC frontmatter
17
+ sections.push("---");
18
+ sections.push("description: Project conventions and constraints extracted by sourcebook");
19
+ sections.push("alwaysApply: true");
20
+ sections.push("---");
21
+ sections.push("");
22
+ // Commands
23
+ if (hasCommands(scan.commands)) {
24
+ sections.push("## Commands");
25
+ sections.push("");
26
+ if (scan.commands.dev)
27
+ sections.push(`- Dev: \`${scan.commands.dev}\``);
28
+ if (scan.commands.build)
29
+ sections.push(`- Build: \`${scan.commands.build}\``);
30
+ if (scan.commands.test)
31
+ sections.push(`- Test: \`${scan.commands.test}\``);
32
+ if (scan.commands.lint)
33
+ sections.push(`- Lint: \`${scan.commands.lint}\``);
34
+ sections.push("");
35
+ }
36
+ // Critical constraints at the top
37
+ if (critical.length > 0) {
38
+ sections.push("## Constraints");
39
+ sections.push("");
40
+ for (const finding of critical) {
41
+ sections.push(`- ${finding.description}`);
42
+ }
43
+ sections.push("");
44
+ }
45
+ // Stack (brief)
46
+ if (scan.frameworks.length > 0) {
47
+ sections.push("## Stack");
48
+ sections.push("");
49
+ sections.push(scan.frameworks.join(", "));
50
+ sections.push("");
51
+ }
52
+ // Core modules
53
+ if (scan.rankedFiles && scan.rankedFiles.length > 0) {
54
+ const top5 = scan.rankedFiles.slice(0, 5);
55
+ sections.push("## Core Modules");
56
+ sections.push("");
57
+ for (const { file } of top5) {
58
+ sections.push(`- \`${file}\``);
59
+ }
60
+ sections.push("");
61
+ }
62
+ // Conventions
63
+ if (important.length > 0) {
64
+ sections.push("## Conventions");
65
+ sections.push("");
66
+ for (const finding of important) {
67
+ sections.push(`- ${finding.description}`);
68
+ }
69
+ sections.push("");
70
+ }
71
+ // Additional context
72
+ if (supplementary.length > 0) {
73
+ sections.push("## Additional Context");
74
+ sections.push("");
75
+ for (const finding of supplementary) {
76
+ sections.push(`- ${finding.description}`);
77
+ }
78
+ sections.push("");
79
+ }
80
+ let output = sections.join("\n");
81
+ // Token budget enforcement
82
+ const charBudget = budget * 4;
83
+ if (output.length > charBudget) {
84
+ output = output.slice(0, charBudget);
85
+ const lastNewline = output.lastIndexOf("\n");
86
+ output = output.slice(0, lastNewline) + "\n";
87
+ }
88
+ return output;
89
+ }
90
+ /**
91
+ * Also generate the legacy .cursorrules format for backwards compatibility.
92
+ * Same content as the .mdc but without the frontmatter.
93
+ */
94
+ export function generateCursorLegacy(scan, budget) {
95
+ const mdc = generateCursor(scan, budget);
96
+ // Strip the YAML frontmatter
97
+ const endOfFrontmatter = mdc.indexOf("---", 4);
98
+ if (endOfFrontmatter !== -1) {
99
+ return mdc.slice(endOfFrontmatter + 4).trimStart();
100
+ }
101
+ return mdc;
102
+ }
103
+ function isCritical(finding) {
104
+ const criticalCategories = new Set([
105
+ "Hidden dependencies",
106
+ "Circular dependencies",
107
+ "Core modules",
108
+ "Fragile code",
109
+ "Git history",
110
+ "Commit conventions",
111
+ ]);
112
+ const criticalKeywords = [
113
+ "breaking", "blast radius", "deprecated", "don't", "must",
114
+ "never", "revert", "fragile", "hidden", "invisible", "coupling",
115
+ ];
116
+ if (criticalCategories.has(finding.category))
117
+ return true;
118
+ const desc = finding.description.toLowerCase();
119
+ return criticalKeywords.some((kw) => desc.includes(kw));
120
+ }
121
+ function hasCommands(commands) {
122
+ return Object.values(commands).some((v) => v !== undefined);
123
+ }
@@ -0,0 +1,2 @@
1
+ import type { BuildCommands } from "../types.js";
2
+ export declare function detectBuildCommands(dir: string): Promise<BuildCommands>;
@@ -0,0 +1,56 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export async function detectBuildCommands(dir) {
4
+ const commands = {};
5
+ // Check package.json scripts
6
+ const pkgPath = path.join(dir, "package.json");
7
+ if (fs.existsSync(pkgPath)) {
8
+ try {
9
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
10
+ const scripts = pkg.scripts || {};
11
+ commands.dev = scripts.dev;
12
+ commands.build = scripts.build;
13
+ commands.test = scripts.test;
14
+ commands.lint = scripts.lint;
15
+ commands.start = scripts.start;
16
+ // Detect non-standard but important scripts
17
+ for (const [name, script] of Object.entries(scripts)) {
18
+ if (typeof script === "string" &&
19
+ !commands[name] &&
20
+ (name.includes("migrate") ||
21
+ name.includes("seed") ||
22
+ name.includes("deploy") ||
23
+ name.includes("typecheck") ||
24
+ name.includes("generate"))) {
25
+ commands[name] = script;
26
+ }
27
+ }
28
+ }
29
+ catch {
30
+ // malformed package.json
31
+ }
32
+ }
33
+ // Check for Makefile
34
+ const makefilePath = path.join(dir, "Makefile");
35
+ if (fs.existsSync(makefilePath) && !commands.build) {
36
+ commands.build = "make";
37
+ }
38
+ // Check for pyproject.toml
39
+ const pyprojectPath = path.join(dir, "pyproject.toml");
40
+ if (fs.existsSync(pyprojectPath)) {
41
+ try {
42
+ const content = fs.readFileSync(pyprojectPath, "utf-8");
43
+ if (content.includes("[tool.poetry.scripts]")) {
44
+ // Poetry project
45
+ if (!commands.dev)
46
+ commands.dev = "poetry run dev";
47
+ if (!commands.test)
48
+ commands.test = "poetry run pytest";
49
+ }
50
+ }
51
+ catch {
52
+ // can't read
53
+ }
54
+ }
55
+ return commands;
56
+ }
@@ -0,0 +1,2 @@
1
+ import type { FrameworkDetection } from "../types.js";
2
+ export declare function detectFrameworks(dir: string, files: string[]): Promise<FrameworkDetection[]>;
@@ -0,0 +1,230 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export async function detectFrameworks(dir, files) {
4
+ const detected = [];
5
+ // Read all package.json files (root + workspaces/sub-packages)
6
+ const pkgFiles = files.filter((f) => f.endsWith("package.json") && !f.includes("node_modules"));
7
+ if (pkgFiles.length === 0)
8
+ pkgFiles.push("package.json");
9
+ const allDeps = {};
10
+ for (const pkgFile of pkgFiles) {
11
+ const pkgPath = path.join(dir, pkgFile);
12
+ if (fs.existsSync(pkgPath)) {
13
+ try {
14
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
15
+ Object.assign(allDeps, pkg.dependencies || {}, pkg.devDependencies || {});
16
+ }
17
+ catch {
18
+ // malformed package.json
19
+ }
20
+ }
21
+ }
22
+ // --- Next.js ---
23
+ if (allDeps["next"]) {
24
+ const findings = [];
25
+ const hasAppDir = files.some((f) => f.startsWith("app/") || f.startsWith("src/app/"));
26
+ const hasPagesDir = files.some((f) => f.startsWith("pages/") || f.startsWith("src/pages/"));
27
+ if (hasAppDir && hasPagesDir) {
28
+ findings.push({
29
+ category: "Next.js routing",
30
+ description: "Project uses BOTH App Router and Pages Router. New routes should go in the app/ directory unless there's a specific reason for pages/.",
31
+ rationale: "Mixed routing is a common migration state. Agents default to pages/ because training data has more examples of it.",
32
+ confidence: "high",
33
+ discoverable: false,
34
+ });
35
+ }
36
+ else if (hasAppDir) {
37
+ findings.push({
38
+ category: "Next.js routing",
39
+ description: "Project uses App Router (app/ directory). Use server components by default.",
40
+ confidence: "high",
41
+ discoverable: true,
42
+ });
43
+ }
44
+ // Check for next.config
45
+ const nextConfig = files.find((f) => /^next\.config\.(js|mjs|ts)$/.test(f));
46
+ if (nextConfig) {
47
+ try {
48
+ const configContent = fs.readFileSync(path.join(dir, nextConfig), "utf-8");
49
+ if (configContent.includes("output:") && configContent.includes("standalone")) {
50
+ findings.push({
51
+ category: "Next.js deployment",
52
+ description: "Standalone output mode is enabled. Build produces a self-contained server in .next/standalone.",
53
+ confidence: "high",
54
+ discoverable: false,
55
+ });
56
+ }
57
+ if (configContent.includes("images") && configContent.includes("remotePatterns")) {
58
+ findings.push({
59
+ category: "Next.js images",
60
+ description: "Remote image patterns are configured. New image domains must be added to next.config before use.",
61
+ rationale: "Agents will try to use next/image with arbitrary URLs and get 400 errors without this config.",
62
+ confidence: "high",
63
+ discoverable: false,
64
+ });
65
+ }
66
+ }
67
+ catch {
68
+ // can't read config
69
+ }
70
+ }
71
+ detected.push({
72
+ name: "Next.js",
73
+ version: allDeps["next"],
74
+ findings: findings.filter((f) => !f.discoverable),
75
+ });
76
+ }
77
+ // --- Expo / React Native ---
78
+ if (allDeps["expo"]) {
79
+ const findings = [];
80
+ const hasExpoRouter = !!allDeps["expo-router"];
81
+ if (hasExpoRouter) {
82
+ findings.push({
83
+ category: "Expo routing",
84
+ description: "Uses Expo Router (file-based routing in app/ directory). Follows Next.js-like conventions.",
85
+ confidence: "high",
86
+ discoverable: true,
87
+ });
88
+ }
89
+ // Check for EAS config
90
+ if (files.includes("eas.json")) {
91
+ findings.push({
92
+ category: "Expo builds",
93
+ description: "EAS Build is configured. Use `eas build` for device builds, not `expo build`.",
94
+ rationale: "expo build is deprecated. Agents trained on older docs will suggest it.",
95
+ confidence: "high",
96
+ discoverable: false,
97
+ });
98
+ }
99
+ // Check app.json for scheme
100
+ const appJsonPath = path.join(dir, "app.json");
101
+ if (fs.existsSync(appJsonPath)) {
102
+ try {
103
+ const appJson = JSON.parse(fs.readFileSync(appJsonPath, "utf-8"));
104
+ if (appJson?.expo?.scheme) {
105
+ findings.push({
106
+ category: "Expo deep linking",
107
+ description: `Deep link scheme is "${appJson.expo.scheme}://". Use this for universal links and navigation.`,
108
+ confidence: "high",
109
+ discoverable: false,
110
+ });
111
+ }
112
+ }
113
+ catch {
114
+ // malformed app.json
115
+ }
116
+ }
117
+ detected.push({
118
+ name: "Expo",
119
+ version: allDeps["expo"],
120
+ findings: findings.filter((f) => !f.discoverable),
121
+ });
122
+ }
123
+ // --- React (standalone, not Next/Expo) ---
124
+ if (allDeps["react"] && !allDeps["next"] && !allDeps["expo"]) {
125
+ const findings = [];
126
+ const hasVite = !!allDeps["vite"];
127
+ if (hasVite) {
128
+ detected.push({ name: "Vite + React", version: allDeps["vite"], findings });
129
+ }
130
+ else {
131
+ detected.push({ name: "React", version: allDeps["react"], findings });
132
+ }
133
+ }
134
+ // --- Supabase ---
135
+ if (allDeps["@supabase/supabase-js"]) {
136
+ const findings = [];
137
+ // Check for RLS awareness
138
+ const hasSupabaseDir = files.some((f) => f.startsWith("supabase/"));
139
+ if (hasSupabaseDir) {
140
+ findings.push({
141
+ category: "Supabase",
142
+ description: "Local Supabase setup detected (supabase/ directory). Use `supabase db push` for migrations, not the dashboard.",
143
+ confidence: "high",
144
+ discoverable: false,
145
+ });
146
+ }
147
+ detected.push({
148
+ name: "Supabase",
149
+ version: allDeps["@supabase/supabase-js"],
150
+ findings,
151
+ });
152
+ }
153
+ // --- Tailwind CSS ---
154
+ if (allDeps["tailwindcss"]) {
155
+ const findings = [];
156
+ const hasTwConfig = files.some((f) => /^tailwind\.config\.(js|ts|mjs|cjs)$/.test(f));
157
+ if (hasTwConfig) {
158
+ try {
159
+ const configPath = files.find((f) => /^tailwind\.config\.(js|ts|mjs|cjs)$/.test(f));
160
+ const content = fs.readFileSync(path.join(dir, configPath), "utf-8");
161
+ if (content.includes("extend") && content.includes("colors")) {
162
+ findings.push({
163
+ category: "Tailwind",
164
+ description: "Custom color tokens are defined in tailwind.config. Use these instead of arbitrary color values.",
165
+ rationale: "Agents default to Tailwind's built-in palette. Using custom tokens keeps the design system consistent.",
166
+ confidence: "medium",
167
+ discoverable: false,
168
+ });
169
+ }
170
+ }
171
+ catch {
172
+ // can't read config
173
+ }
174
+ }
175
+ detected.push({
176
+ name: "Tailwind CSS",
177
+ version: allDeps["tailwindcss"],
178
+ findings,
179
+ });
180
+ }
181
+ // --- Express ---
182
+ if (allDeps["express"]) {
183
+ detected.push({
184
+ name: "Express",
185
+ version: allDeps["express"],
186
+ findings: [],
187
+ });
188
+ }
189
+ // --- TypeScript ---
190
+ if (allDeps["typescript"]) {
191
+ const findings = [];
192
+ const tsconfigPath = path.join(dir, "tsconfig.json");
193
+ if (fs.existsSync(tsconfigPath)) {
194
+ try {
195
+ const tsconfig = JSON.parse(fs.readFileSync(tsconfigPath, "utf-8"));
196
+ const strict = tsconfig?.compilerOptions?.strict;
197
+ if (strict === false) {
198
+ findings.push({
199
+ category: "TypeScript",
200
+ description: "Strict mode is OFF. Don't add strict type annotations that would break existing patterns.",
201
+ confidence: "high",
202
+ discoverable: false,
203
+ });
204
+ }
205
+ const paths = tsconfig?.compilerOptions?.paths;
206
+ if (paths) {
207
+ const aliases = Object.keys(paths)
208
+ .map((k) => k.replace("/*", ""))
209
+ .join(", ");
210
+ findings.push({
211
+ category: "TypeScript imports",
212
+ description: `Path aliases configured: ${aliases}. Use these instead of relative imports.`,
213
+ rationale: "Agents default to relative imports. Path aliases keep imports clean.",
214
+ confidence: "high",
215
+ discoverable: false,
216
+ });
217
+ }
218
+ }
219
+ catch {
220
+ // malformed tsconfig
221
+ }
222
+ }
223
+ detected.push({
224
+ name: "TypeScript",
225
+ version: allDeps["typescript"],
226
+ findings,
227
+ });
228
+ }
229
+ return detected;
230
+ }
@@ -0,0 +1,17 @@
1
+ import type { Finding } from "../types.js";
2
+ interface GitAnalysis {
3
+ findings: Finding[];
4
+ activeAreas: string[];
5
+ revertedPatterns: string[];
6
+ coChangeClusters: [string, string, number][];
7
+ }
8
+ /**
9
+ * Mine git history for non-obvious context:
10
+ * - Reverted commits (literal "don't do this" signals)
11
+ * - Co-change coupling (invisible dependencies)
12
+ * - Recently active areas
13
+ * - Commit message patterns (module structure)
14
+ * - Rapid re-edits (code that was hard to get right)
15
+ */
16
+ export declare function analyzeGitHistory(dir: string): Promise<GitAnalysis>;
17
+ export {};