prjct-cli 1.3.0 → 1.5.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.
@@ -30,11 +30,13 @@ import commandInstaller from '../infrastructure/command-installer'
30
30
  import configManager from '../infrastructure/config-manager'
31
31
  import pathManager from '../infrastructure/path-manager'
32
32
  import { metricsStorage } from '../storage/metrics-storage'
33
+ import { type ContextSources, defaultSources, type SourceInfo } from '../utils/citations'
33
34
  import dateHelper from '../utils/date-helper'
34
35
  import { ContextFileGenerator } from './context-generator'
35
36
  import type { SyncDiff } from './diff-generator'
36
37
  import { localStateGenerator } from './local-state-generator'
37
38
  import { type StackDetection, StackDetector } from './stack-detector'
39
+ import { syncVerifier, type VerificationReport } from './sync-verifier'
38
40
 
39
41
  const execAsync = promisify(exec)
40
42
 
@@ -106,6 +108,7 @@ interface SyncResult {
106
108
  contextFiles: string[]
107
109
  aiTools: AIToolResult[]
108
110
  syncMetrics?: SyncMetrics
111
+ verification?: VerificationReport
109
112
  error?: string
110
113
  // Preview mode fields
111
114
  isPreview?: boolean
@@ -200,7 +203,8 @@ class SyncService {
200
203
  // 4. Generate all files (depends on gathered data)
201
204
  const agents = await this.generateAgents(stack, stats)
202
205
  const skills = this.configureSkills(agents)
203
- const contextFiles = await this.generateContextFiles(git, stats, commands, agents)
206
+ const sources = this.buildSources(stats, commands)
207
+ const contextFiles = await this.generateContextFiles(git, stats, commands, agents, sources)
204
208
 
205
209
  // 5. Generate AI tool context files (multi-agent output)
206
210
  const projectContext: ProjectContext = {
@@ -221,6 +225,7 @@ class SyncService {
221
225
  workflow: agents.filter((a) => a.type === 'workflow').map((a) => a.name),
222
226
  domain: agents.filter((a) => a.type === 'domain').map((a) => a.name),
223
227
  },
228
+ sources,
224
229
  }
225
230
 
226
231
  const aiToolResults = await generateAIToolContexts(
@@ -246,6 +251,19 @@ class SyncService {
246
251
  await commandInstaller.installGlobalConfig()
247
252
  await commandInstaller.syncCommands()
248
253
 
254
+ // 11. Run verification checks (built-in + custom from config)
255
+ let verification: VerificationReport | undefined
256
+ try {
257
+ const localConfig = await configManager.readConfig(this.projectPath)
258
+ verification = await syncVerifier.verify(
259
+ this.projectPath,
260
+ this.globalPath,
261
+ localConfig?.verification
262
+ )
263
+ } catch {
264
+ // Verification is non-critical — don't fail sync
265
+ }
266
+
249
267
  return {
250
268
  success: true,
251
269
  projectId: this.projectId,
@@ -263,6 +281,7 @@ class SyncService {
263
281
  success: r.success,
264
282
  })),
265
283
  syncMetrics,
284
+ verification,
266
285
  }
267
286
  } catch (error) {
268
287
  return {
@@ -519,6 +538,54 @@ class SyncService {
519
538
  return commands
520
539
  }
521
540
 
541
+ // ==========================================================================
542
+ // SOURCE CITATIONS
543
+ // ==========================================================================
544
+
545
+ private buildSources(stats: ProjectStats, commands: Commands): ContextSources {
546
+ const sources = defaultSources()
547
+
548
+ // Determine ecosystem source file
549
+ const ecosystemFiles: Record<string, string> = {
550
+ JavaScript: 'package.json',
551
+ Rust: 'Cargo.toml',
552
+ Go: 'go.mod',
553
+ Python: 'pyproject.toml',
554
+ }
555
+ const ecosystemFile = ecosystemFiles[stats.ecosystem] || 'filesystem'
556
+ const detected = (file: string): SourceInfo => ({ file, type: 'detected' })
557
+ const inferred = (file: string): SourceInfo => ({ file, type: 'inferred' })
558
+
559
+ sources.ecosystem = detected(ecosystemFile)
560
+ sources.name = detected(ecosystemFile)
561
+ sources.version = detected(ecosystemFile)
562
+ sources.languages = detected(ecosystemFile)
563
+ sources.frameworks = detected(ecosystemFile)
564
+
565
+ // Commands source is the lock file or ecosystem file
566
+ if (commands.install.startsWith('bun')) {
567
+ sources.commands = detected('bun.lockb')
568
+ } else if (commands.install.startsWith('pnpm')) {
569
+ sources.commands = detected('pnpm-lock.yaml')
570
+ } else if (commands.install === 'yarn') {
571
+ sources.commands = detected('yarn.lock')
572
+ } else if (commands.install.startsWith('cargo')) {
573
+ sources.commands = detected('Cargo.toml')
574
+ } else if (commands.install.startsWith('go')) {
575
+ sources.commands = detected('go.mod')
576
+ } else {
577
+ sources.commands = detected('package.json')
578
+ }
579
+
580
+ // Project type is inferred from file count + framework count
581
+ sources.projectType = inferred('file count + frameworks')
582
+
583
+ // Git is always from git
584
+ sources.git = detected('git')
585
+
586
+ return sources
587
+ }
588
+
522
589
  // ==========================================================================
523
590
  // STACK DETECTION
524
591
  // ==========================================================================
@@ -741,7 +808,8 @@ You are the ${name} expert for this project. Apply best practices for the detect
741
808
  git: GitData,
742
809
  stats: ProjectStats,
743
810
  commands: Commands,
744
- agents: AgentInfo[]
811
+ agents: AgentInfo[],
812
+ sources?: ContextSources
745
813
  ): Promise<string[]> {
746
814
  const generator = new ContextFileGenerator({
747
815
  projectId: this.projectId!,
@@ -749,7 +817,13 @@ You are the ${name} expert for this project. Apply best practices for the detect
749
817
  globalPath: this.globalPath,
750
818
  })
751
819
 
752
- return generator.generate({ branch: git.branch, commits: git.commits }, stats, commands, agents)
820
+ return generator.generate(
821
+ { branch: git.branch, commits: git.commits },
822
+ stats,
823
+ commands,
824
+ agents,
825
+ sources
826
+ )
753
827
  }
754
828
 
755
829
  // ==========================================================================
@@ -0,0 +1,273 @@
1
+ /**
2
+ * SyncVerifier - Programmatic verification checks for sync workflow
3
+ *
4
+ * Runs configurable checks after sync to validate generated output.
5
+ * Supports built-in checks and custom user commands.
6
+ *
7
+ * @see PRJ-106
8
+ */
9
+
10
+ import { exec } from 'node:child_process'
11
+ import fs from 'node:fs/promises'
12
+ import path from 'node:path'
13
+ import { promisify } from 'node:util'
14
+ import { isNotFoundError } from '../types/fs'
15
+
16
+ const execAsync = promisify(exec)
17
+
18
+ // =============================================================================
19
+ // TYPES
20
+ // =============================================================================
21
+
22
+ export interface VerificationCheck {
23
+ name: string
24
+ command?: string
25
+ script?: string
26
+ enabled?: boolean
27
+ }
28
+
29
+ export interface VerificationConfig {
30
+ checks?: VerificationCheck[]
31
+ failFast?: boolean
32
+ }
33
+
34
+ export interface CheckResult {
35
+ name: string
36
+ passed: boolean
37
+ output?: string
38
+ error?: string
39
+ durationMs: number
40
+ }
41
+
42
+ export interface VerificationReport {
43
+ passed: boolean
44
+ checks: CheckResult[]
45
+ totalMs: number
46
+ failedCount: number
47
+ passedCount: number
48
+ skippedCount: number
49
+ }
50
+
51
+ // =============================================================================
52
+ // BUILT-IN CHECKS
53
+ // =============================================================================
54
+
55
+ const BUILTIN_CHECKS = {
56
+ /**
57
+ * Verify all expected context files exist after sync
58
+ */
59
+ async contextFilesExist(globalPath: string): Promise<CheckResult> {
60
+ const start = Date.now()
61
+ const expected = ['context/CLAUDE.md']
62
+ const missing: string[] = []
63
+
64
+ for (const file of expected) {
65
+ const filePath = path.join(globalPath, file)
66
+ try {
67
+ await fs.access(filePath)
68
+ } catch {
69
+ missing.push(file)
70
+ }
71
+ }
72
+
73
+ return {
74
+ name: 'Context files exist',
75
+ passed: missing.length === 0,
76
+ output: missing.length === 0 ? `${expected.length} files verified` : undefined,
77
+ error: missing.length > 0 ? `Missing: ${missing.join(', ')}` : undefined,
78
+ durationMs: Date.now() - start,
79
+ }
80
+ },
81
+
82
+ /**
83
+ * Verify generated JSON files are valid
84
+ */
85
+ async jsonFilesValid(globalPath: string): Promise<CheckResult> {
86
+ const start = Date.now()
87
+ const jsonFiles = ['storage/state.json']
88
+ const invalid: string[] = []
89
+
90
+ for (const file of jsonFiles) {
91
+ const filePath = path.join(globalPath, file)
92
+ try {
93
+ const content = await fs.readFile(filePath, 'utf-8')
94
+ JSON.parse(content)
95
+ } catch (error) {
96
+ if (!isNotFoundError(error)) {
97
+ invalid.push(`${file}: ${error instanceof SyntaxError ? 'invalid JSON' : 'read error'}`)
98
+ }
99
+ }
100
+ }
101
+
102
+ return {
103
+ name: 'JSON files valid',
104
+ passed: invalid.length === 0,
105
+ output: invalid.length === 0 ? `${jsonFiles.length} files validated` : undefined,
106
+ error: invalid.length > 0 ? invalid.join('; ') : undefined,
107
+ durationMs: Date.now() - start,
108
+ }
109
+ },
110
+
111
+ /**
112
+ * Verify no sensitive data leaked into context files
113
+ */
114
+ async noSensitiveData(globalPath: string): Promise<CheckResult> {
115
+ const start = Date.now()
116
+ const contextDir = path.join(globalPath, 'context')
117
+ const patterns = [
118
+ /(?:api[_-]?key|apikey)\s*[:=]\s*['"][^'"]{10,}/i,
119
+ /(?:password|passwd|pwd)\s*[:=]\s*['"][^'"]{4,}/i,
120
+ /(?:secret|token)\s*[:=]\s*['"][^'"]{10,}/i,
121
+ ]
122
+ const violations: string[] = []
123
+
124
+ try {
125
+ const files = await fs.readdir(contextDir)
126
+ for (const file of files) {
127
+ if (!file.endsWith('.md')) continue
128
+ const content = await fs.readFile(path.join(contextDir, file), 'utf-8')
129
+ for (const pattern of patterns) {
130
+ if (pattern.test(content)) {
131
+ violations.push(`${file}: potential sensitive data detected`)
132
+ break
133
+ }
134
+ }
135
+ }
136
+ } catch (error) {
137
+ if (!isNotFoundError(error)) {
138
+ return {
139
+ name: 'No sensitive data',
140
+ passed: false,
141
+ error: `Could not scan: ${(error as Error).message}`,
142
+ durationMs: Date.now() - start,
143
+ }
144
+ }
145
+ }
146
+
147
+ return {
148
+ name: 'No sensitive data',
149
+ passed: violations.length === 0,
150
+ output: violations.length === 0 ? 'No sensitive patterns found' : undefined,
151
+ error: violations.length > 0 ? violations.join('; ') : undefined,
152
+ durationMs: Date.now() - start,
153
+ }
154
+ },
155
+ }
156
+
157
+ // =============================================================================
158
+ // SYNC VERIFIER
159
+ // =============================================================================
160
+
161
+ class SyncVerifier {
162
+ /**
163
+ * Run all verification checks (built-in + custom)
164
+ */
165
+ async verify(
166
+ projectPath: string,
167
+ globalPath: string,
168
+ config?: VerificationConfig
169
+ ): Promise<VerificationReport> {
170
+ const totalStart = Date.now()
171
+ const checks: CheckResult[] = []
172
+ const failFast = config?.failFast ?? false
173
+ let skipped = 0
174
+
175
+ // 1. Run built-in checks
176
+ const builtinChecks = [
177
+ BUILTIN_CHECKS.contextFilesExist(globalPath),
178
+ BUILTIN_CHECKS.jsonFilesValid(globalPath),
179
+ BUILTIN_CHECKS.noSensitiveData(globalPath),
180
+ ]
181
+
182
+ for (const checkPromise of builtinChecks) {
183
+ const result = await checkPromise
184
+ checks.push(result)
185
+ if (!result.passed && failFast) {
186
+ skipped = config?.checks?.filter((c) => c.enabled !== false).length ?? 0
187
+ break
188
+ }
189
+ }
190
+
191
+ // 2. Run custom checks (if configured and not fail-fast-stopped)
192
+ const shouldContinue = !failFast || checks.every((c) => c.passed)
193
+ if (shouldContinue && config?.checks) {
194
+ for (const check of config.checks) {
195
+ if (check.enabled === false) {
196
+ skipped++
197
+ continue
198
+ }
199
+
200
+ const result = await this.runCustomCheck(check, projectPath)
201
+ checks.push(result)
202
+
203
+ if (!result.passed && failFast) {
204
+ // Count remaining enabled checks as skipped
205
+ const remaining = config.checks.slice(config.checks.indexOf(check) + 1)
206
+ skipped += remaining.filter((c) => c.enabled !== false).length
207
+ break
208
+ }
209
+ }
210
+ }
211
+
212
+ const failedCount = checks.filter((c) => !c.passed).length
213
+ const passedCount = checks.filter((c) => c.passed).length
214
+
215
+ return {
216
+ passed: failedCount === 0,
217
+ checks,
218
+ totalMs: Date.now() - totalStart,
219
+ failedCount,
220
+ passedCount,
221
+ skippedCount: skipped,
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Run a single custom verification check
227
+ */
228
+ private async runCustomCheck(
229
+ check: VerificationCheck,
230
+ projectPath: string
231
+ ): Promise<CheckResult> {
232
+ const start = Date.now()
233
+ const command = check.command || (check.script ? `sh ${check.script}` : null)
234
+
235
+ if (!command) {
236
+ return {
237
+ name: check.name,
238
+ passed: false,
239
+ error: 'No command or script specified',
240
+ durationMs: Date.now() - start,
241
+ }
242
+ }
243
+
244
+ try {
245
+ const { stdout, stderr } = await execAsync(command, {
246
+ cwd: projectPath,
247
+ timeout: 30_000,
248
+ })
249
+
250
+ return {
251
+ name: check.name,
252
+ passed: true,
253
+ output: (stdout.trim() || stderr.trim()).slice(0, 200) || undefined,
254
+ durationMs: Date.now() - start,
255
+ }
256
+ } catch (error) {
257
+ const execError = error as { stdout?: string; stderr?: string; message: string }
258
+ return {
259
+ name: check.name,
260
+ passed: false,
261
+ error: (execError.stderr?.trim() || execError.message).slice(0, 200),
262
+ durationMs: Date.now() - start,
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ // =============================================================================
269
+ // EXPORTS
270
+ // =============================================================================
271
+
272
+ export const syncVerifier = new SyncVerifier()
273
+ export default syncVerifier
@@ -18,6 +18,20 @@ export interface LocalConfig {
18
18
  * @see PRJ-70
19
19
  */
20
20
  showMetrics?: boolean
21
+ /**
22
+ * Verification checks to run after sync.
23
+ * Built-in checks always run; custom checks are additive.
24
+ * @see PRJ-106
25
+ */
26
+ verification?: {
27
+ checks?: Array<{
28
+ name: string
29
+ command?: string
30
+ script?: string
31
+ enabled?: boolean
32
+ }>
33
+ failFast?: boolean
34
+ }
21
35
  }
22
36
 
23
37
  /**
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Citation utilities for context source tracking
3
+ *
4
+ * Generates HTML comments indicating where each section's data came from.
5
+ * Source types: detected (from files), user-defined (from config), inferred (from heuristics)
6
+ *
7
+ * @see PRJ-113
8
+ */
9
+
10
+ export type SourceType = 'detected' | 'user-defined' | 'inferred'
11
+
12
+ export interface SourceInfo {
13
+ file: string
14
+ type: SourceType
15
+ }
16
+
17
+ export interface ContextSources {
18
+ name: SourceInfo
19
+ version: SourceInfo
20
+ ecosystem: SourceInfo
21
+ languages: SourceInfo
22
+ frameworks: SourceInfo
23
+ commands: SourceInfo
24
+ projectType: SourceInfo
25
+ git: SourceInfo
26
+ }
27
+
28
+ /**
29
+ * Generate an HTML citation comment
30
+ *
31
+ * @example cite({ file: 'package.json', type: 'detected' })
32
+ * // => '<!-- source: package.json, detected -->'
33
+ */
34
+ export function cite(source: SourceInfo): string {
35
+ return `<!-- source: ${source.file}, ${source.type} -->`
36
+ }
37
+
38
+ /**
39
+ * Create default sources (all unknown) - used as fallback
40
+ */
41
+ export function defaultSources(): ContextSources {
42
+ const unknown: SourceInfo = { file: 'unknown', type: 'detected' }
43
+ return {
44
+ name: { ...unknown },
45
+ version: { ...unknown },
46
+ ecosystem: { ...unknown },
47
+ languages: { ...unknown },
48
+ frameworks: { ...unknown },
49
+ commands: { ...unknown },
50
+ projectType: { ...unknown },
51
+ git: { file: 'git', type: 'detected' },
52
+ }
53
+ }
@@ -128,6 +128,17 @@ export const ERRORS = {
128
128
  hint: "Run 'prjct start' to configure your provider",
129
129
  },
130
130
 
131
+ // Command errors
132
+ UNKNOWN_COMMAND: {
133
+ message: 'Unknown command',
134
+ hint: "Run 'prjct --help' to see available commands",
135
+ },
136
+
137
+ MISSING_PARAM: {
138
+ message: 'Missing required parameter',
139
+ hint: 'Check command usage below',
140
+ },
141
+
131
142
  // Generic
132
143
  UNKNOWN: {
133
144
  message: 'An unexpected error occurred',