anchormd 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,125 @@
1
+ /**
2
+ * Entity extraction from plan content
3
+ *
4
+ * Extracts references to:
5
+ * - File paths (strings with / and common extensions)
6
+ * - Models (PascalCase words near model/schema/table/entity keywords)
7
+ * - Routes (HTTP method + path patterns)
8
+ * - Scripts (shell script filenames and npm run commands)
9
+ */
10
+
11
+ import type { Entity } from './types.js';
12
+
13
+ /**
14
+ * Extract file path references from content.
15
+ * Matches paths containing `/` with common source file extensions.
16
+ */
17
+ function extractFilePaths(content: string): Entity[] {
18
+ const regex = /(?:^|\s|['"`(])([a-zA-Z0-9._\-/]+\.(?:ts|js|tsx|jsx|py|go|rs|sql|json|yaml|yml|md|css|html|sh|toml|env))\b/gm;
19
+ const entities: Entity[] = [];
20
+ let match: RegExpExecArray | null;
21
+
22
+ while ((match = regex.exec(content)) !== null) {
23
+ const value = match[1];
24
+ // Only include paths that contain a slash (to avoid bare filenames matching too broadly)
25
+ // Exception: script files like deploy.sh are handled by extractScripts
26
+ if (value.includes('/')) {
27
+ entities.push({ type: 'file', value });
28
+ }
29
+ }
30
+
31
+ return entities;
32
+ }
33
+
34
+ /**
35
+ * Extract model/schema references from content.
36
+ * Matches PascalCase words near model/schema/table/entity keywords.
37
+ */
38
+ function extractModels(content: string): Entity[] {
39
+ const entities: Entity[] = [];
40
+
41
+ // Pattern: keyword followed by PascalCase name
42
+ const forwardRegex = /(?:model|schema|table|entity|Model|Schema)\s+([A-Z][a-zA-Z0-9]+)/g;
43
+ let match: RegExpExecArray | null;
44
+
45
+ while ((match = forwardRegex.exec(content)) !== null) {
46
+ entities.push({ type: 'model', value: match[1] });
47
+ }
48
+
49
+ // Pattern: PascalCase name followed by keyword
50
+ const reverseRegex = /([A-Z][a-zA-Z0-9]+)\s+(?:model|schema|table|entity)/g;
51
+
52
+ while ((match = reverseRegex.exec(content)) !== null) {
53
+ entities.push({ type: 'model', value: match[1] });
54
+ }
55
+
56
+ return entities;
57
+ }
58
+
59
+ /**
60
+ * Extract HTTP route references from content.
61
+ * Matches HTTP methods followed by URL paths.
62
+ */
63
+ function extractRoutes(content: string): Entity[] {
64
+ const regex = /\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\/[a-zA-Z0-9/:._\-{}*]+)/g;
65
+ const entities: Entity[] = [];
66
+ let match: RegExpExecArray | null;
67
+
68
+ while ((match = regex.exec(content)) !== null) {
69
+ entities.push({ type: 'route', value: `${match[1]} ${match[2]}` });
70
+ }
71
+
72
+ return entities;
73
+ }
74
+
75
+ /**
76
+ * Extract script references from content.
77
+ * Matches shell script filenames and npm run commands.
78
+ */
79
+ function extractScripts(content: string): Entity[] {
80
+ const entities: Entity[] = [];
81
+
82
+ // Shell script filenames
83
+ const scriptRegex = /(?:^|\s|['"`(])([a-zA-Z0-9_\-]+\.(?:sh|bash|zsh))\b/gm;
84
+ let match: RegExpExecArray | null;
85
+
86
+ while ((match = scriptRegex.exec(content)) !== null) {
87
+ entities.push({ type: 'script', value: match[1] });
88
+ }
89
+
90
+ // npm run commands
91
+ const npmRegex = /npm\s+run\s+([a-zA-Z0-9_\-:]+)/g;
92
+
93
+ while ((match = npmRegex.exec(content)) !== null) {
94
+ entities.push({ type: 'script', value: `npm run ${match[1]}` });
95
+ }
96
+
97
+ return entities;
98
+ }
99
+
100
+ /**
101
+ * Extract all entities from plan content.
102
+ * Combines results from all extractors and deduplicates by type+value.
103
+ */
104
+ export function extractEntities(content: string): Entity[] {
105
+ const all = [
106
+ ...extractFilePaths(content),
107
+ ...extractModels(content),
108
+ ...extractRoutes(content),
109
+ ...extractScripts(content),
110
+ ];
111
+
112
+ // Deduplicate by type+value
113
+ const seen = new Set<string>();
114
+ const unique: Entity[] = [];
115
+
116
+ for (const entity of all) {
117
+ const key = `${entity.type}:${entity.value}`;
118
+ if (!seen.has(key)) {
119
+ seen.add(key);
120
+ unique.push(entity);
121
+ }
122
+ }
123
+
124
+ return unique;
125
+ }
package/src/format.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Output formatting with ANSI colors
3
+ *
4
+ * Respects NO_COLOR environment variable (https://no-color.org/)
5
+ */
6
+
7
+ import type { PlanFile, PlanStatus, SearchResult } from './types.js';
8
+
9
+ const noColor = 'NO_COLOR' in process.env;
10
+
11
+ function wrap(code: string, s: string): string {
12
+ return noColor ? s : `\x1b[${code}m${s}\x1b[0m`;
13
+ }
14
+
15
+ /** ANSI color helpers */
16
+ export const color = {
17
+ bold: (s: string) => wrap('1', s),
18
+ dim: (s: string) => wrap('2', s),
19
+ green: (s: string) => wrap('32', s),
20
+ yellow: (s: string) => wrap('33', s),
21
+ cyan: (s: string) => wrap('36', s),
22
+ red: (s: string) => wrap('31', s),
23
+ blue: (s: string) => wrap('34', s),
24
+ };
25
+
26
+ /** Color a status string based on its value */
27
+ function colorStatus(status: PlanStatus): string {
28
+ switch (status) {
29
+ case 'built':
30
+ return color.green(status);
31
+ case 'in-progress':
32
+ return color.yellow(status);
33
+ case 'planned':
34
+ return color.cyan(status);
35
+ case 'deprecated':
36
+ return color.red(status);
37
+ default:
38
+ return status;
39
+ }
40
+ }
41
+
42
+ /** Strip ANSI codes for length calculation */
43
+ function stripAnsi(s: string): string {
44
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
45
+ }
46
+
47
+ /**
48
+ * Format a single plan as a table row
49
+ */
50
+ export function formatPlanRow(plan: PlanFile): string {
51
+ const status = colorStatus(plan.frontmatter.status);
52
+ return ` ${color.bold(plan.frontmatter.name)} ${status} ${color.dim(plan.frontmatter.description)}`;
53
+ }
54
+
55
+ /**
56
+ * Format a list of plans as a columnar table
57
+ */
58
+ export function formatPlanTable(plans: PlanFile[]): string {
59
+ if (plans.length === 0) {
60
+ return color.dim(' No plans found.');
61
+ }
62
+
63
+ // Calculate column widths
64
+ const nameWidth = Math.max(...plans.map(p => p.frontmatter.name.length), 4);
65
+ const statusWidth = Math.max(...plans.map(p => p.frontmatter.status.length), 6);
66
+
67
+ const lines: string[] = [];
68
+
69
+ // Header
70
+ const header = ` ${'NAME'.padEnd(nameWidth)} ${'STATUS'.padEnd(statusWidth)} DESCRIPTION`;
71
+ lines.push(color.dim(header));
72
+ lines.push(color.dim(' ' + '-'.repeat(nameWidth + statusWidth + 20)));
73
+
74
+ // Rows
75
+ for (const plan of plans) {
76
+ const name = color.bold(plan.frontmatter.name.padEnd(nameWidth));
77
+ const status = colorStatus(plan.frontmatter.status);
78
+ // Pad status accounting for ANSI codes
79
+ const statusPad = ' '.repeat(Math.max(0, statusWidth - plan.frontmatter.status.length));
80
+ const desc = color.dim(plan.frontmatter.description);
81
+ lines.push(` ${name} ${status}${statusPad} ${desc}`);
82
+ }
83
+
84
+ return lines.join('\n');
85
+ }
86
+
87
+ /**
88
+ * Format search results as a numbered list
89
+ */
90
+ export function formatSearchResults(results: SearchResult[]): string {
91
+ if (results.length === 0) {
92
+ return color.dim(' No results found.');
93
+ }
94
+
95
+ const lines: string[] = [];
96
+
97
+ for (let i = 0; i < results.length; i++) {
98
+ const r = results[i];
99
+ const num = color.dim(`${i + 1}.`);
100
+ const score = color.yellow(`[${r.score.toFixed(3)}]`);
101
+ const path = color.bold(r.path);
102
+ lines.push(` ${num} ${score} ${path}`);
103
+
104
+ if (r.content) {
105
+ // Show a truncated preview of the content
106
+ const preview = r.content.substring(0, 120).replace(/\n/g, ' ').trim();
107
+ lines.push(` ${color.dim(preview)}`);
108
+ }
109
+ }
110
+
111
+ return lines.join('\n');
112
+ }
113
+
114
+ /**
115
+ * Format project status information
116
+ */
117
+ export function formatStatus(stats: {
118
+ planCount: number;
119
+ linkCount: number;
120
+ weakEdgeCount: number;
121
+ qmdEnabled: boolean;
122
+ }): string {
123
+ const lines = [
124
+ color.bold('AnchorMD Status'),
125
+ '',
126
+ ` Plans: ${color.cyan(String(stats.planCount))}`,
127
+ ` Links: ${color.cyan(String(stats.linkCount))}`,
128
+ ` Weak edges: ${color.cyan(String(stats.weakEdgeCount))}`,
129
+ ` QMD search: ${stats.qmdEnabled ? color.green('enabled') : color.yellow('disabled')}`,
130
+ '',
131
+ ];
132
+
133
+ return lines.join('\n');
134
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Index graph builder and I/O
3
+ *
4
+ * Builds a graph of plan relationships from:
5
+ * - Strong links: explicit [[plan]] references
6
+ * - Weak edges: shared entities between plans (same file, model, route, or script)
7
+ */
8
+
9
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { listPlans } from './plan.js';
12
+ import { parseLinks, getStrongLinks } from './links.js';
13
+ import { extractEntities } from './entities.js';
14
+ import { getPlansDir, getAnchorDir } from './config.js';
15
+ import type { IndexGraph, IndexGraphNode, Entity } from './types.js';
16
+
17
+ /**
18
+ * Build the index graph from all plans in the plans directory.
19
+ *
20
+ * For each plan:
21
+ * 1. Extract strong links (explicit [[references]])
22
+ * 2. Extract entities (file paths, models, routes, scripts)
23
+ * 3. Compute weak edges (plans sharing the same entity)
24
+ */
25
+ export function buildIndex(plansDir: string): IndexGraph {
26
+ const plans = listPlans(plansDir);
27
+
28
+ // Map of entityKey -> list of plan names that reference it
29
+ const entityToPlanMap = new Map<string, string[]>();
30
+
31
+ // Build initial nodes and populate the entity map
32
+ const nodes: Record<string, IndexGraphNode> = {};
33
+
34
+ for (const plan of plans) {
35
+ const name = plan.frontmatter.name;
36
+ const fullContent = plan.body;
37
+
38
+ // Extract strong links (target names)
39
+ const links = parseLinks(fullContent);
40
+ const strongLinks = getStrongLinks(links).map(l => l.target);
41
+
42
+ // Extract entities
43
+ const entities = extractEntities(fullContent);
44
+
45
+ // Register entities in the map
46
+ for (const entity of entities) {
47
+ const key = `${entity.type}:${entity.value}`;
48
+ const existing = entityToPlanMap.get(key) || [];
49
+ if (!existing.includes(name)) {
50
+ existing.push(name);
51
+ entityToPlanMap.set(key, existing);
52
+ }
53
+ }
54
+
55
+ nodes[name] = {
56
+ name,
57
+ links: strongLinks,
58
+ entities,
59
+ weakEdges: [], // Filled in below
60
+ };
61
+ }
62
+
63
+ // Compute weak edges from shared entities
64
+ for (const planNames of entityToPlanMap.values()) {
65
+ if (planNames.length < 2) continue;
66
+
67
+ // All plans sharing this entity get weak edges to each other
68
+ for (const planA of planNames) {
69
+ for (const planB of planNames) {
70
+ if (planA === planB) continue;
71
+ const node = nodes[planA];
72
+ if (node && !node.weakEdges.includes(planB)) {
73
+ node.weakEdges.push(planB);
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ return {
80
+ nodes,
81
+ lastBuilt: new Date().toISOString(),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Write the index graph to .anchor/index.json
87
+ */
88
+ export function writeIndex(anchorDir: string, graph: IndexGraph): void {
89
+ mkdirSync(anchorDir, { recursive: true });
90
+ const indexPath = path.join(anchorDir, 'index.json');
91
+ writeFileSync(indexPath, JSON.stringify(graph, null, 2) + '\n', 'utf-8');
92
+ }
93
+
94
+ /**
95
+ * Read the index graph from .anchor/index.json
96
+ * Returns null if the file doesn't exist.
97
+ */
98
+ export function readIndex(anchorDir: string): IndexGraph | null {
99
+ const indexPath = path.join(anchorDir, 'index.json');
100
+
101
+ if (!existsSync(indexPath)) {
102
+ return null;
103
+ }
104
+
105
+ try {
106
+ const raw = readFileSync(indexPath, 'utf-8');
107
+ return JSON.parse(raw) as IndexGraph;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Rebuild the index and write it to disk.
115
+ * Returns the built graph.
116
+ */
117
+ export function rebuildAndWriteIndex(projectRoot: string): IndexGraph {
118
+ const plansDir = getPlansDir(projectRoot);
119
+ const anchorDir = getAnchorDir(projectRoot);
120
+ const graph = buildIndex(plansDir);
121
+ writeIndex(anchorDir, graph);
122
+ return graph;
123
+ }
package/src/links.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Link parser for [[wiki-style]] links in plan content
3
+ *
4
+ * Supports:
5
+ * - Strong links: [[target]]
6
+ * - Deep links: [[target#section]]
7
+ * - Multi-hash deep links: [[target#section#subsection]]
8
+ */
9
+
10
+ import type { Link, StrongLink, DeepLink } from './types.js';
11
+
12
+ const LINK_REGEX = /\[\[([^\]]+)\]\]/g;
13
+
14
+ /**
15
+ * Type guard: returns true if the link has a section (is a DeepLink)
16
+ */
17
+ export function isDeepLink(link: Link): link is DeepLink {
18
+ return 'section' in link;
19
+ }
20
+
21
+ /**
22
+ * Parse all [[wiki-style]] links from content.
23
+ * Returns deduplicated array of StrongLink and DeepLink objects.
24
+ */
25
+ export function parseLinks(content: string): Link[] {
26
+ const seen = new Set<string>();
27
+ const links: Link[] = [];
28
+
29
+ let match: RegExpExecArray | null;
30
+ // Reset regex state
31
+ LINK_REGEX.lastIndex = 0;
32
+
33
+ while ((match = LINK_REGEX.exec(content)) !== null) {
34
+ const raw = match[1];
35
+ const hashIndex = raw.indexOf('#');
36
+
37
+ let link: Link;
38
+ let key: string;
39
+
40
+ if (hashIndex !== -1) {
41
+ const target = raw.substring(0, hashIndex);
42
+ const section = raw.substring(hashIndex + 1);
43
+ link = { target, section };
44
+ key = `${target}#${section}`;
45
+ } else {
46
+ link = { target: raw };
47
+ key = raw;
48
+ }
49
+
50
+ if (!seen.has(key)) {
51
+ seen.add(key);
52
+ links.push(link);
53
+ }
54
+ }
55
+
56
+ return links;
57
+ }
58
+
59
+ /**
60
+ * Filter links to only strong links (no section)
61
+ */
62
+ export function getStrongLinks(links: Link[]): StrongLink[] {
63
+ return links.filter((link): link is StrongLink => !isDeepLink(link));
64
+ }
65
+
66
+ /**
67
+ * Filter links to only deep links (with section)
68
+ */
69
+ export function getDeepLinks(links: Link[]): DeepLink[] {
70
+ return links.filter(isDeepLink);
71
+ }
package/src/plan.ts ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Plan file parsing and I/O
3
+ *
4
+ * Plans are markdown files with YAML frontmatter:
5
+ * ---
6
+ * name: plan-name
7
+ * description: What this plan covers
8
+ * status: planned
9
+ * tags: [optional, tags]
10
+ * ---
11
+ * # Body content here
12
+ */
13
+
14
+ import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { parse as yamlParse, stringify as yamlStringify } from 'yaml';
17
+ import type { PlanFrontmatter, PlanFile, PlanStatus } from './types.js';
18
+ import { VALID_STATUSES } from './types.js';
19
+
20
+ /**
21
+ * Parse frontmatter and body from raw plan content.
22
+ * Expects content starting with `---` delimiter.
23
+ */
24
+ export function parseFrontmatter(raw: string): { frontmatter: PlanFrontmatter; body: string } {
25
+ const trimmed = raw.trim();
26
+
27
+ if (!trimmed.startsWith('---')) {
28
+ throw new Error('Plan file must start with --- frontmatter delimiter');
29
+ }
30
+
31
+ // Find the closing --- delimiter
32
+ const secondDelimiter = trimmed.indexOf('---', 3);
33
+ if (secondDelimiter === -1) {
34
+ throw new Error('Plan file missing closing --- frontmatter delimiter');
35
+ }
36
+
37
+ const yamlContent = trimmed.substring(3, secondDelimiter).trim();
38
+ const body = trimmed.substring(secondDelimiter + 3).replace(/^\n/, '');
39
+
40
+ const parsed = yamlParse(yamlContent);
41
+
42
+ if (!parsed || typeof parsed !== 'object') {
43
+ throw new Error('Invalid frontmatter YAML');
44
+ }
45
+
46
+ if (!parsed.name || typeof parsed.name !== 'string') {
47
+ throw new Error('Frontmatter missing required field: name');
48
+ }
49
+
50
+ if (!parsed.description || typeof parsed.description !== 'string') {
51
+ throw new Error('Frontmatter missing required field: description');
52
+ }
53
+
54
+ if (!parsed.status || typeof parsed.status !== 'string') {
55
+ throw new Error('Frontmatter missing required field: status');
56
+ }
57
+
58
+ if (!VALID_STATUSES.includes(parsed.status as PlanStatus)) {
59
+ throw new Error(
60
+ `Invalid status "${parsed.status}". Must be one of: ${VALID_STATUSES.join(', ')}`
61
+ );
62
+ }
63
+
64
+ const frontmatter: PlanFrontmatter = {
65
+ name: parsed.name,
66
+ description: parsed.description,
67
+ status: parsed.status as PlanStatus,
68
+ };
69
+
70
+ if (parsed.tags && Array.isArray(parsed.tags)) {
71
+ frontmatter.tags = parsed.tags;
72
+ }
73
+
74
+ return { frontmatter, body };
75
+ }
76
+
77
+ /**
78
+ * Serialize a plan to its string representation (frontmatter + body)
79
+ */
80
+ export function serializePlan(frontmatter: PlanFrontmatter, body: string): string {
81
+ const yamlStr = yamlStringify(frontmatter);
82
+ return `---\n${yamlStr}---\n${body}`;
83
+ }
84
+
85
+ /**
86
+ * Read and parse a single plan file by name (without .md extension)
87
+ */
88
+ export function readPlan(plansDir: string, name: string): PlanFile {
89
+ const filePath = path.join(plansDir, name + '.md');
90
+
91
+ if (!existsSync(filePath)) {
92
+ throw new Error(`Plan not found: ${name} (looked for ${filePath})`);
93
+ }
94
+
95
+ const raw = readFileSync(filePath, 'utf-8');
96
+ const { frontmatter, body } = parseFrontmatter(raw);
97
+
98
+ return {
99
+ frontmatter,
100
+ body,
101
+ filename: name + '.md',
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Write plan content to a file in the plans directory
107
+ */
108
+ export function writePlan(plansDir: string, name: string, content: string): void {
109
+ mkdirSync(plansDir, { recursive: true });
110
+ const filePath = path.join(plansDir, name + '.md');
111
+ writeFileSync(filePath, content, 'utf-8');
112
+ }
113
+
114
+ /**
115
+ * List and parse all plan files in the plans directory
116
+ */
117
+ export function listPlans(plansDir: string): PlanFile[] {
118
+ if (!existsSync(plansDir)) {
119
+ return [];
120
+ }
121
+
122
+ const files = readdirSync(plansDir).filter(f => f.endsWith('.md')).sort();
123
+ const plans: PlanFile[] = [];
124
+
125
+ for (const file of files) {
126
+ const filePath = path.join(plansDir, file);
127
+ try {
128
+ const raw = readFileSync(filePath, 'utf-8');
129
+ const { frontmatter, body } = parseFrontmatter(raw);
130
+ plans.push({
131
+ frontmatter,
132
+ body,
133
+ filename: file,
134
+ });
135
+ } catch {
136
+ // Skip files that fail to parse (e.g., malformed frontmatter)
137
+ continue;
138
+ }
139
+ }
140
+
141
+ return plans;
142
+ }