prjct-cli 1.4.0 → 1.5.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +123 -1
  2. package/bin/prjct.ts +23 -14
  3. package/core/__tests__/agentic/command-executor.test.ts +19 -19
  4. package/core/__tests__/agentic/prompt-builder.test.ts +16 -16
  5. package/core/__tests__/ai-tools/formatters.test.ts +118 -0
  6. package/core/agentic/command-executor.ts +18 -17
  7. package/core/agentic/prompt-builder.ts +18 -17
  8. package/core/agentic/template-executor.ts +2 -2
  9. package/core/ai-tools/formatters.ts +18 -0
  10. package/core/ai-tools/registry.ts +17 -14
  11. package/core/cli/start.ts +18 -17
  12. package/core/commands/analysis.ts +1 -1
  13. package/core/commands/setup.ts +8 -8
  14. package/core/commands/uninstall.ts +11 -11
  15. package/core/index.ts +103 -21
  16. package/core/infrastructure/agent-detector.ts +8 -8
  17. package/core/infrastructure/ai-provider.ts +49 -37
  18. package/core/infrastructure/command-installer.ts +18 -10
  19. package/core/infrastructure/path-manager.ts +4 -4
  20. package/core/infrastructure/setup.ts +124 -119
  21. package/core/infrastructure/update-checker.ts +14 -13
  22. package/core/integrations/linear/sync.ts +4 -4
  23. package/core/services/context-generator.ts +12 -3
  24. package/core/services/hooks-service.ts +78 -68
  25. package/core/services/sync-service.ts +64 -6
  26. package/core/utils/citations.ts +53 -0
  27. package/core/utils/error-messages.ts +11 -0
  28. package/core/utils/fs-helpers.ts +14 -0
  29. package/core/utils/project-credentials.ts +8 -7
  30. package/dist/bin/prjct.mjs +854 -643
  31. package/dist/core/infrastructure/command-installer.js +118 -87
  32. package/dist/core/infrastructure/setup.js +246 -210
  33. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,12 +1,134 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.1] - 2026-02-06
4
+
5
+ ### Refactoring
6
+
7
+ - standardize on fs/promises across codebase (PRJ-93) (#118)
8
+
9
+
10
+ ## [1.5.1] - 2026-02-06
11
+
12
+ ### Changed
13
+
14
+ - **Standardize on fs/promises across codebase (PRJ-93)**: Replaced all synchronous `fs` operations (`existsSync`, `readFileSync`, `writeFileSync`, `mkdirSync`, etc.) with async `fs/promises` equivalents across 22 files
15
+
16
+ ### Implementation Details
17
+
18
+ - Created shared `fileExists()` utility in `core/utils/fs-helpers.ts` replacing `existsSync` with `fs.access`
19
+ - Converted all detection functions in `ai-provider.ts` to async with `Promise.all` for parallel checks
20
+ - Applied lazy initialization pattern in `CommandInstaller` to handle async `getActiveProvider()` in constructor
21
+ - Replaced `execSync` with `promisify(exec)` in `registry.ts` and `ai-provider.ts`
22
+ - Converted `setup.ts` (~60 sync ops), `hooks-service.ts` (~30 sync ops), and all command/CLI files
23
+ - Updated prompt-builder and command-executor tests to handle async `build()` and `signalStart/End()`
24
+ - Intentional exceptions: `version.ts` (module-level constants), `jsonl-helper.ts` (`createReadStream`), test files
25
+
26
+ ### Learnings
27
+
28
+ - Module-level constants (`VERSION`, `PACKAGE_ROOT`) cannot use async — sync reads at import time are a valid exception
29
+ - `createReadStream` is inherently sync (returns a stream) — the correct pattern for streaming reads
30
+ - Making a function async cascades to all callers — `ai-provider.ts` changes rippled to 10+ files
31
+ - Constructor methods can't be async — solved with lazy `ensureInit()` pattern in `CommandInstaller`
32
+
33
+ ### Test Plan
34
+
35
+ #### For QA
36
+ 1. Run `bun run build` — verify clean build with no errors
37
+ 2. Run `bun test` — verify all 416 tests pass
38
+ 3. Run `prjct sync` on a project — verify async fs operations work correctly
39
+ 4. Run `prjct start` — verify setup flow works with async file operations
40
+ 5. Verify `prjct linear list` works (uses converted linear/sync.ts)
41
+
42
+ #### For Users
43
+ **What changed:** Internal refactor — no user-facing API changes. All sync filesystem operations replaced with async equivalents for better performance.
44
+ **How to use:** No changes needed. All commands work identically.
45
+ **Breaking changes:** None
46
+
47
+ ## [1.5.0] - 2026-02-06
48
+
49
+ ### Features
50
+
51
+ - add citation format for context sources in templates (PRJ-113) (#117)
52
+
53
+ ## [1.4.2] - 2026-02-06
54
+
55
+ ### Features
56
+
57
+ - **Source citations in context files (PRJ-113)**: All generated context files now include `<!-- source: file, type -->` HTML comments showing where each section's data was detected from
58
+
59
+ ### Implementation Details
60
+
61
+ - Added `SourceInfo` type and `ContextSources` interface with `cite()` helper (`core/utils/citations.ts`)
62
+ - Extended `ProjectContext` with optional `sources` field — backward compatible, falls back to `defaultSources()`
63
+ - Added `buildSources()` to `sync-service.ts` — maps detected ecosystem/commands data to their source files (package.json, lock files, Cargo.toml, etc.)
64
+ - Citations added to 4 markdown formatters: Claude, Cursor, Windsurf, Copilot. Continue.dev skipped (JSON has no comment syntax)
65
+ - Context generator CLAUDE.md also updated with citation support
66
+ - Source types: `detected` (from files), `user-defined` (from config), `inferred` (from heuristics)
67
+
68
+ ### Learnings
69
+
70
+ - `context-generator.ts` and `formatters.ts` both independently generate CLAUDE.md content — both must be updated for consistent citations
71
+ - Sources can be determined post-detection from data values rather than threading metadata through every detection method
72
+ - Optional fields with fallback defaults (`sources?`) maintain backward compatibility without breaking existing callers
73
+
74
+ ### Test Plan
75
+
76
+ #### For QA
77
+ 1. Run `prjct sync --yes` — verify generated context files contain `<!-- source: ... -->` comments
78
+ 2. Check CLAUDE.md citations before: THIS PROJECT, Commands, Code Conventions, PROJECT STATE
79
+ 3. Check `.cursor/rules/prjct.mdc` citations before Tech Stack and Commands
80
+ 4. Check `.windsurf/rules/prjct.md` citations before Stack and Commands
81
+ 5. Check `.github/copilot-instructions.md` citations before Project Info and Commands
82
+ 6. Verify `.continue/config.json` unchanged (JSON has no comments)
83
+ 7. Run `bun test` — all 416 tests pass
84
+
85
+ #### For Users
86
+ **What changed:** Context files now show where data came from via HTML comments
87
+ **How to use:** Run `p. sync` — citations appear automatically
88
+ **Breaking changes:** None
89
+
90
+ ## [1.4.1] - 2026-02-06
91
+
92
+ ### Improvements
93
+
94
+ - **Better error messages for invalid commands (PRJ-98)**: Consistent, helpful CLI errors with did-you-mean suggestions and required parameter validation
95
+
96
+ ### Implementation Details
97
+
98
+ - Added `UNKNOWN_COMMAND` and `MISSING_PARAM` error codes to centralized error catalog (`core/utils/error-messages.ts`)
99
+ - Added `validateCommandParams()` — parses `CommandMeta.params` convention (`<required>` vs `[optional]`) and validates against actual CLI args before command execution
100
+ - Added `findClosestCommand()` with Levenshtein edit distance (threshold ≤ 2) for did-you-mean suggestions on typos
101
+ - All error paths now use `out.failWithHint()` for consistent formatting with hints, docs links, and file references
102
+ - Deprecated and unimplemented command errors also upgraded to use `failWithHint()`
103
+
104
+ ### Learnings
105
+
106
+ - `bin/prjct.ts` intercepts many commands (start, context, hooks, doctor, etc.) before `core/index.ts` — changes to dispatch only affect commands that reach core
107
+ - Template-only commands (e.g. `task`) are defined in `command-data.ts` but not registered in the command registry — they don't get param validation via CLI
108
+ - Levenshtein edit distance is simple to implement (~15 lines) and effective for CLI typo suggestions
109
+
110
+ ### Test Plan
111
+
112
+ #### For QA
113
+ 1. `prjct xyzzy` → "Unknown command: xyzzy" with help hint
114
+ 2. `prjct snyc` → "Did you mean 'prjct sync'?"
115
+ 3. `prjct shp` → "Did you mean 'prjct ship'?"
116
+ 4. `prjct bug` (no args) → "Missing required parameter: description" with usage
117
+ 5. `prjct idea` (no args) → "Missing required parameter: description" with usage
118
+ 6. `prjct sync --yes` → works normally (no regression)
119
+ 7. `prjct dash compact` → works normally (no regression)
120
+
121
+ #### For Users
122
+ **What changed:** CLI now shows helpful error messages with suggestions when you mistype a command or forget a required argument.
123
+ **How to use:** Just use prjct normally — errors are now more helpful automatically.
124
+ **Breaking changes:** None
125
+
3
126
  ## [1.4.0] - 2026-02-06
4
127
 
5
128
  ### Features
6
129
 
7
130
  - programmatic verification checks for sync workflow (PRJ-106) (#115)
8
131
 
9
-
10
132
  ## [1.3.1] - 2026-02-06
11
133
 
12
134
  ### Features
package/bin/prjct.ts CHANGED
@@ -8,7 +8,6 @@
8
8
  * auto-install on first CLI use. This is the reliable path.
9
9
  */
10
10
 
11
- import fs from 'node:fs'
12
11
  import os from 'node:os'
13
12
  import path from 'node:path'
14
13
  import chalk from 'chalk'
@@ -16,20 +15,21 @@ import { detectAllProviders } from '../core/infrastructure/ai-provider'
16
15
  import configManager from '../core/infrastructure/config-manager'
17
16
  import editorsConfig from '../core/infrastructure/editors-config'
18
17
  import { DEFAULT_PORT, startServer } from '../core/server/server'
18
+ import { fileExists } from '../core/utils/fs-helpers'
19
19
  import { VERSION } from '../core/utils/version'
20
20
 
21
21
  /**
22
22
  * Check if routers are installed for detected providers
23
23
  * Returns true if at least one provider has its router installed
24
24
  */
25
- function checkRoutersInstalled(): boolean {
25
+ async function checkRoutersInstalled(): Promise<boolean> {
26
26
  const home = os.homedir()
27
- const detection = detectAllProviders()
27
+ const detection = await detectAllProviders()
28
28
 
29
29
  // Check Claude router
30
30
  if (detection.claude.installed) {
31
31
  const claudeRouter = path.join(home, '.claude', 'commands', 'p.md')
32
- if (!fs.existsSync(claudeRouter)) {
32
+ if (!(await fileExists(claudeRouter))) {
33
33
  return false
34
34
  }
35
35
  }
@@ -37,7 +37,7 @@ function checkRoutersInstalled(): boolean {
37
37
  // Check Gemini router
38
38
  if (detection.gemini.installed) {
39
39
  const geminiRouter = path.join(home, '.gemini', 'commands', 'p.toml')
40
- if (!fs.existsSync(geminiRouter)) {
40
+ if (!(await fileExists(geminiRouter))) {
41
41
  return false
42
42
  }
43
43
  }
@@ -218,15 +218,24 @@ if (args[0] === 'start' || args[0] === 'setup') {
218
218
  process.exitCode = 0
219
219
  } else if (args[0] === 'version' || args[0] === '-v' || args[0] === '--version') {
220
220
  // Show version with provider status
221
- const detection = detectAllProviders()
221
+ const detection = await detectAllProviders()
222
222
  const home = os.homedir()
223
223
  const cwd = process.cwd()
224
- const claudeConfigured = fs.existsSync(path.join(home, '.claude', 'commands', 'p.md'))
225
- const geminiConfigured = fs.existsSync(path.join(home, '.gemini', 'commands', 'p.toml'))
226
- const cursorDetected = fs.existsSync(path.join(cwd, '.cursor'))
227
- const cursorConfigured = fs.existsSync(path.join(cwd, '.cursor', 'rules', 'prjct.mdc'))
228
- const windsurfDetected = fs.existsSync(path.join(cwd, '.windsurf'))
229
- const windsurfConfigured = fs.existsSync(path.join(cwd, '.windsurf', 'rules', 'prjct.md'))
224
+ const [
225
+ claudeConfigured,
226
+ geminiConfigured,
227
+ cursorDetected,
228
+ cursorConfigured,
229
+ windsurfDetected,
230
+ windsurfConfigured,
231
+ ] = await Promise.all([
232
+ fileExists(path.join(home, '.claude', 'commands', 'p.md')),
233
+ fileExists(path.join(home, '.gemini', 'commands', 'p.toml')),
234
+ fileExists(path.join(cwd, '.cursor')),
235
+ fileExists(path.join(cwd, '.cursor', 'rules', 'prjct.mdc')),
236
+ fileExists(path.join(cwd, '.windsurf')),
237
+ fileExists(path.join(cwd, '.windsurf', 'rules', 'prjct.md')),
238
+ ])
230
239
 
231
240
  console.log(`
232
241
  ${chalk.cyan('p/')} prjct v${VERSION}
@@ -276,9 +285,9 @@ ${chalk.cyan('https://prjct.app')}
276
285
  } else {
277
286
  // Check if setup has been done
278
287
  const configPath = path.join(os.homedir(), '.prjct-cli', 'config', 'installed-editors.json')
279
- const routersInstalled = checkRoutersInstalled()
288
+ const routersInstalled = await checkRoutersInstalled()
280
289
 
281
- if (!fs.existsSync(configPath) || !routersInstalled) {
290
+ if (!(await fileExists(configPath)) || !routersInstalled) {
282
291
  // First time - prompt to run start
283
292
  console.log(`
284
293
  ${chalk.cyan.bold(' Welcome to prjct!')}
@@ -106,40 +106,40 @@ describe('CommandExecutor', () => {
106
106
  }
107
107
  })
108
108
 
109
- it('should create status file with command name', () => {
110
- signalStart('test-command')
109
+ it('should create status file with command name', async () => {
110
+ await signalStart('test-command')
111
111
 
112
112
  expect(fs.existsSync(RUNNING_FILE)).toBe(true)
113
113
  const content = fs.readFileSync(RUNNING_FILE, 'utf-8')
114
114
  expect(content).toBe('/p:test-command')
115
115
  })
116
116
 
117
- it('should overwrite existing status file', () => {
118
- signalStart('first-command')
119
- signalStart('second-command')
117
+ it('should overwrite existing status file', async () => {
118
+ await signalStart('first-command')
119
+ await signalStart('second-command')
120
120
 
121
121
  const content = fs.readFileSync(RUNNING_FILE, 'utf-8')
122
122
  expect(content).toBe('/p:second-command')
123
123
  })
124
124
 
125
- it('should handle filesystem errors gracefully', () => {
125
+ it('should handle filesystem errors gracefully', async () => {
126
126
  // This test verifies that errors are silently ignored
127
127
  // We can't easily simulate fs errors, but we can verify the function doesn't throw
128
- expect(() => signalStart('test-command')).not.toThrow()
128
+ await expect(signalStart('test-command')).resolves.toBeUndefined()
129
129
  })
130
130
  })
131
131
 
132
132
  describe('signalEnd', () => {
133
- it('should remove status file if it exists', () => {
133
+ it('should remove status file if it exists', async () => {
134
134
  // Create the file first
135
- signalStart('test-command')
135
+ await signalStart('test-command')
136
136
  expect(fs.existsSync(RUNNING_FILE)).toBe(true)
137
137
 
138
- signalEnd()
138
+ await signalEnd()
139
139
  expect(fs.existsSync(RUNNING_FILE)).toBe(false)
140
140
  })
141
141
 
142
- it('should not throw if file does not exist', () => {
142
+ it('should not throw if file does not exist', async () => {
143
143
  // Ensure file doesn't exist
144
144
  try {
145
145
  fs.unlinkSync(RUNNING_FILE)
@@ -147,7 +147,7 @@ describe('CommandExecutor', () => {
147
147
  // Ignore
148
148
  }
149
149
 
150
- expect(() => signalEnd()).not.toThrow()
150
+ await expect(signalEnd()).resolves.toBeUndefined()
151
151
  })
152
152
  })
153
153
 
@@ -158,20 +158,20 @@ describe('CommandExecutor', () => {
158
158
  executor = new CommandExecutor()
159
159
  })
160
160
 
161
- it('should have signalStart method that calls module function', () => {
162
- executor.signalStart('class-test')
161
+ it('should have signalStart method that calls module function', async () => {
162
+ await executor.signalStart('class-test')
163
163
 
164
164
  expect(fs.existsSync(RUNNING_FILE)).toBe(true)
165
165
  const content = fs.readFileSync(RUNNING_FILE, 'utf-8')
166
166
  expect(content).toBe('/p:class-test')
167
167
 
168
168
  // Cleanup
169
- executor.signalEnd()
169
+ await executor.signalEnd()
170
170
  })
171
171
 
172
- it('should have signalEnd method that calls module function', () => {
173
- executor.signalStart('class-test')
174
- executor.signalEnd()
172
+ it('should have signalEnd method that calls module function', async () => {
173
+ await executor.signalStart('class-test')
174
+ await executor.signalEnd()
175
175
 
176
176
  expect(fs.existsSync(RUNNING_FILE)).toBe(false)
177
177
  })
@@ -336,7 +336,7 @@ describe('execute', () => {
336
336
  memorySystem.getRelevantMemories = mock(() => Promise.resolve([]))
337
337
 
338
338
  // Mock promptBuilder
339
- promptBuilder.build = mock(() => 'built prompt')
339
+ promptBuilder.build = mock(() => Promise.resolve('built prompt'))
340
340
  })
341
341
 
342
342
  afterEach(() => {
@@ -96,7 +96,7 @@ describe('PromptBuilder', () => {
96
96
  })
97
97
 
98
98
  describe('Context Filtering by Command Type', () => {
99
- it('should include patterns for code commands', () => {
99
+ it('should include patterns for code commands', async () => {
100
100
  const template = {
101
101
  frontmatter: { description: 'Build feature', name: 'p:build' },
102
102
  content: '## Flow\nBuild something',
@@ -105,13 +105,13 @@ describe('PromptBuilder', () => {
105
105
  const context = { projectPath: '/test', files: ['file1.js'] }
106
106
  const state = { analysis: 'Stack: Node.js, React' }
107
107
 
108
- const prompt = builder.build(template, context, state)
108
+ const prompt = await builder.build(template, context, state)
109
109
 
110
110
  expect(prompt).toContain('PATTERNS')
111
111
  expect(prompt).toContain('Node.js')
112
112
  })
113
113
 
114
- it('should NOT include patterns for non-code commands', () => {
114
+ it('should NOT include patterns for non-code commands', async () => {
115
115
  const template = {
116
116
  frontmatter: { description: 'Show current task', name: 'p:now' },
117
117
  content: '## Flow\nShow task',
@@ -120,14 +120,14 @@ describe('PromptBuilder', () => {
120
120
  const context = { projectPath: '/test', files: ['file1.js'] }
121
121
  const state = { analysis: 'Stack: Node.js, React' }
122
122
 
123
- const prompt = builder.build(template, context, state)
123
+ const prompt = await builder.build(template, context, state)
124
124
 
125
125
  expect(prompt).not.toContain('## PATTERNS')
126
126
  })
127
127
  })
128
128
 
129
129
  describe('Project Files Listing', () => {
130
- it('should list available files when context has files', () => {
130
+ it('should list available files when context has files', async () => {
131
131
  const template = {
132
132
  frontmatter: { description: 'Test command' },
133
133
  content: '## Flow\nDo something',
@@ -140,7 +140,7 @@ describe('PromptBuilder', () => {
140
140
 
141
141
  const state = {}
142
142
 
143
- const prompt = builder.build(template, context, state)
143
+ const prompt = await builder.build(template, context, state)
144
144
 
145
145
  expect(prompt).toContain('## FILES:')
146
146
  expect(prompt).toContain('3 available')
@@ -148,7 +148,7 @@ describe('PromptBuilder', () => {
148
148
  expect(prompt).toContain('Read')
149
149
  })
150
150
 
151
- it('should show project path when no files listed', () => {
151
+ it('should show project path when no files listed', async () => {
152
152
  const template = {
153
153
  frontmatter: { description: 'Test command' },
154
154
  content: '## Flow\nDo something',
@@ -157,7 +157,7 @@ describe('PromptBuilder', () => {
157
157
  const context = { projectPath: '/test/project' }
158
158
  const state = {}
159
159
 
160
- const prompt = builder.build(template, context, state)
160
+ const prompt = await builder.build(template, context, state)
161
161
 
162
162
  expect(prompt).toContain('## PROJECT:')
163
163
  expect(prompt).toContain('/test/project')
@@ -165,7 +165,7 @@ describe('PromptBuilder', () => {
165
165
  })
166
166
 
167
167
  describe('Build Complete Prompt', () => {
168
- it('should include all critical sections', () => {
168
+ it('should include all critical sections', async () => {
169
169
  const template = {
170
170
  frontmatter: {
171
171
  description: 'Test command',
@@ -182,7 +182,7 @@ describe('PromptBuilder', () => {
182
182
 
183
183
  const state = { now: '# NOW\n\n**Current task**' }
184
184
 
185
- const prompt = builder.build(template, context, state)
185
+ const prompt = await builder.build(template, context, state)
186
186
 
187
187
  expect(prompt).toContain('TASK:')
188
188
  expect(prompt).toContain('TOOLS:')
@@ -191,7 +191,7 @@ describe('PromptBuilder', () => {
191
191
  expect(prompt).toContain('## FILES:')
192
192
  })
193
193
 
194
- it('should be concise (under 2000 chars for simple prompt)', () => {
194
+ it('should be concise (under 2000 chars for simple prompt)', async () => {
195
195
  const template = {
196
196
  frontmatter: { description: 'Test', 'allowed-tools': ['Read'] },
197
197
  content: '## Flow\n1. Test',
@@ -200,14 +200,14 @@ describe('PromptBuilder', () => {
200
200
  const context = { projectPath: '/test', files: ['a.js'] }
201
201
  const state = {}
202
202
 
203
- const prompt = builder.build(template, context, state)
203
+ const prompt = await builder.build(template, context, state)
204
204
 
205
205
  expect(prompt.length).toBeLessThan(2000)
206
206
  })
207
207
  })
208
208
 
209
209
  describe('Plan Mode (Compressed)', () => {
210
- it('should include compact plan mode instructions', () => {
210
+ it('should include compact plan mode instructions', async () => {
211
211
  const template = {
212
212
  frontmatter: { description: 'Test' },
213
213
  content: '## Flow\nTest',
@@ -217,14 +217,14 @@ describe('PromptBuilder', () => {
217
217
  const state = {}
218
218
  const planInfo = { isPlanning: true, allowedTools: ['Read', 'Glob'] }
219
219
 
220
- const prompt = builder.build(template, context, state, null, null, null, null, planInfo)
220
+ const prompt = await builder.build(template, context, state, null, null, null, null, planInfo)
221
221
 
222
222
  expect(prompt).toContain('PLAN MODE')
223
223
  expect(prompt).toContain('Read-only')
224
224
  expect(prompt).toContain('Tools: Read, Glob')
225
225
  })
226
226
 
227
- it('should include approval required section', () => {
227
+ it('should include approval required section', async () => {
228
228
  const template = {
229
229
  frontmatter: { description: 'Test' },
230
230
  content: '## Flow\nTest',
@@ -234,7 +234,7 @@ describe('PromptBuilder', () => {
234
234
  const state = {}
235
235
  const planInfo = { requiresApproval: true }
236
236
 
237
- const prompt = builder.build(template, context, state, null, null, null, null, planInfo)
237
+ const prompt = await builder.build(template, context, state, null, null, null, null, planInfo)
238
238
 
239
239
  expect(prompt).toContain('APPROVAL REQUIRED')
240
240
  expect(prompt).toContain('confirmation')
@@ -2,6 +2,7 @@
2
2
  * Tests for AI Tools Formatters
3
3
  *
4
4
  * @see PRJ-122
5
+ * @see PRJ-113 (citation support)
5
6
  */
6
7
 
7
8
  import { describe, expect, test } from 'bun:test'
@@ -15,6 +16,7 @@ import {
15
16
  type ProjectContext,
16
17
  } from '../../ai-tools/formatters'
17
18
  import { AI_TOOLS, getAIToolConfig } from '../../ai-tools/registry'
19
+ import { type ContextSources, cite, defaultSources } from '../../utils/citations'
18
20
 
19
21
  // =============================================================================
20
22
  // Test Fixtures
@@ -356,3 +358,119 @@ describe('Formatter Edge Cases', () => {
356
358
  expect(claudeResult).toContain('@scope/my-project')
357
359
  })
358
360
  })
361
+
362
+ // =============================================================================
363
+ // Citation Tests (PRJ-113)
364
+ // =============================================================================
365
+
366
+ const mockSources: ContextSources = {
367
+ name: { file: 'package.json', type: 'detected' },
368
+ version: { file: 'package.json', type: 'detected' },
369
+ ecosystem: { file: 'package.json', type: 'detected' },
370
+ languages: { file: 'package.json', type: 'detected' },
371
+ frameworks: { file: 'package.json', type: 'detected' },
372
+ commands: { file: 'bun.lockb', type: 'detected' },
373
+ projectType: { file: 'file count + frameworks', type: 'inferred' },
374
+ git: { file: 'git', type: 'detected' },
375
+ }
376
+
377
+ const ctxWithSources: ProjectContext = {
378
+ ...mockContext,
379
+ sources: mockSources,
380
+ }
381
+
382
+ describe('cite() helper', () => {
383
+ test('generates HTML comment with file and type', () => {
384
+ const result = cite({ file: 'package.json', type: 'detected' })
385
+ expect(result).toBe('<!-- source: package.json, detected -->')
386
+ })
387
+
388
+ test('supports inferred type', () => {
389
+ const result = cite({ file: 'file count + frameworks', type: 'inferred' })
390
+ expect(result).toBe('<!-- source: file count + frameworks, inferred -->')
391
+ })
392
+
393
+ test('supports user-defined type', () => {
394
+ const result = cite({ file: 'prjct.yaml', type: 'user-defined' })
395
+ expect(result).toBe('<!-- source: prjct.yaml, user-defined -->')
396
+ })
397
+ })
398
+
399
+ describe('defaultSources()', () => {
400
+ test('returns all source fields', () => {
401
+ const sources = defaultSources()
402
+ expect(sources.name).toBeDefined()
403
+ expect(sources.version).toBeDefined()
404
+ expect(sources.ecosystem).toBeDefined()
405
+ expect(sources.languages).toBeDefined()
406
+ expect(sources.frameworks).toBeDefined()
407
+ expect(sources.commands).toBeDefined()
408
+ expect(sources.projectType).toBeDefined()
409
+ expect(sources.git).toBeDefined()
410
+ })
411
+
412
+ test('git source is always "git"', () => {
413
+ const sources = defaultSources()
414
+ expect(sources.git.file).toBe('git')
415
+ expect(sources.git.type).toBe('detected')
416
+ })
417
+ })
418
+
419
+ describe('Citation integration', () => {
420
+ test('Claude format includes source citations', () => {
421
+ const result = formatForClaude(ctxWithSources, AI_TOOLS.claude)
422
+
423
+ expect(result).toContain('<!-- source: package.json, detected -->')
424
+ expect(result).toContain('<!-- source: bun.lockb, detected -->')
425
+ })
426
+
427
+ test('Cursor format includes source citations', () => {
428
+ const result = formatForCursor(ctxWithSources, AI_TOOLS.cursor)
429
+
430
+ expect(result).toContain('<!-- source: package.json, detected -->')
431
+ expect(result).toContain('<!-- source: bun.lockb, detected -->')
432
+ })
433
+
434
+ test('Windsurf format includes source citations', () => {
435
+ const result = formatForWindsurf(ctxWithSources, AI_TOOLS.windsurf)
436
+
437
+ expect(result).toContain('<!-- source: package.json, detected -->')
438
+ expect(result).toContain('<!-- source: bun.lockb, detected -->')
439
+ })
440
+
441
+ test('Copilot format includes source citations', () => {
442
+ const result = formatForCopilot(ctxWithSources, AI_TOOLS.copilot)
443
+
444
+ expect(result).toContain('<!-- source: package.json, detected -->')
445
+ expect(result).toContain('<!-- source: bun.lockb, detected -->')
446
+ })
447
+
448
+ test('formatters work without sources (backward compatible)', () => {
449
+ const ctxNoSources = { ...mockContext }
450
+ delete ctxNoSources.sources
451
+
452
+ // Should not throw
453
+ expect(() => formatForClaude(ctxNoSources, AI_TOOLS.claude)).not.toThrow()
454
+ expect(() => formatForCursor(ctxNoSources, AI_TOOLS.cursor)).not.toThrow()
455
+ expect(() => formatForWindsurf(ctxNoSources, AI_TOOLS.windsurf)).not.toThrow()
456
+ expect(() => formatForCopilot(ctxNoSources, AI_TOOLS.copilot)).not.toThrow()
457
+
458
+ // Should still contain default citation comments
459
+ const result = formatForClaude(ctxNoSources, AI_TOOLS.claude)
460
+ expect(result).toContain('<!-- source:')
461
+ })
462
+
463
+ test('Claude format has citations before each major section', () => {
464
+ const result = formatForClaude(ctxWithSources, AI_TOOLS.claude)
465
+
466
+ // Ecosystem citation before project type
467
+ const ecosystemIdx = result.indexOf('<!-- source: package.json, detected -->')
468
+ const projectTypeIdx = result.indexOf('**Type:**')
469
+ expect(ecosystemIdx).toBeLessThan(projectTypeIdx)
470
+
471
+ // Commands citation before commands table
472
+ const commandsCiteIdx = result.indexOf('<!-- source: bun.lockb, detected -->')
473
+ const commandsTableIdx = result.indexOf('| Action | Command |')
474
+ expect(commandsCiteIdx).toBeLessThan(commandsTableIdx)
475
+ })
476
+ })