prjct-cli 1.3.0 → 1.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.0] - 2026-02-06
4
+
5
+ ### Features
6
+
7
+ - programmatic verification checks for sync workflow (PRJ-106) (#115)
8
+
9
+
10
+ ## [1.3.1] - 2026-02-06
11
+
12
+ ### Features
13
+
14
+ - **Programmatic verification checks for sync workflow (PRJ-106)**: Post-sync validation with built-in and custom checks
15
+
16
+ ### Implementation Details
17
+
18
+ New `SyncVerifier` service (`core/services/sync-verifier.ts`) that runs verification checks after every sync. Three built-in checks run automatically:
19
+ - **Context files exist** — verifies `context/CLAUDE.md` was generated
20
+ - **JSON files valid** — validates `storage/state.json` syntax
21
+ - **No sensitive data** — scans context files for leaked API keys, passwords, secrets
22
+
23
+ Custom checks configurable in `.prjct/prjct.config.json`:
24
+ ```json
25
+ {
26
+ "verification": {
27
+ "checks": [
28
+ { "name": "Lint CLAUDE.md", "command": "npx markdownlint CLAUDE.md" },
29
+ { "name": "Custom validator", "script": ".prjct/verify.sh" }
30
+ ],
31
+ "failFast": false
32
+ }
33
+ }
34
+ ```
35
+
36
+ Integration: wired into `sync-service.ts` after file generation (step 11), results returned in `SyncResult.verification`. Display in `showSyncResult()` shows pass/fail per check with timing.
37
+
38
+ ### Learnings
39
+
40
+ - Non-critical verification must be wrapped in try/catch so it never breaks the sync workflow
41
+ - Config types must match optional fields between `LocalConfig` and `VerificationConfig` (both `checks` must be optional)
42
+ - Built-in + custom extensibility pattern (always run built-ins, then user commands) provides good defaults with flexibility
43
+
44
+ ### Test Plan
45
+
46
+ #### For QA
47
+ 1. Run `prjct sync --yes` — verify "Verified" section with 3 checks passing
48
+ 2. Add custom check to `.prjct/prjct.config.json` — verify it runs after sync
49
+ 3. Add failing custom check (`command: "exit 1"`) — verify `✗` with error
50
+ 4. Set `failFast: true` with failing check — verify remaining checks skipped
51
+ 5. Run `bun run build && bun run typecheck` — zero errors
52
+
53
+ #### For Users
54
+ **What changed:** `prjct sync` now validates generated output with pass/fail checks
55
+ **How to use:** Built-in checks run automatically. Add custom checks in `.prjct/prjct.config.json`
56
+ **Breaking changes:** None
57
+
3
58
  ## [1.3.0] - 2026-02-06
4
59
 
5
60
  ### Features
@@ -529,6 +529,28 @@ export class AnalysisCommands extends PrjctCommandsBase {
529
529
  console.log('')
530
530
  }
531
531
 
532
+ // ═══════════════════════════════════════════════════════════════════════
533
+ // VERIFICATION - Post-sync validation checks
534
+ // ═══════════════════════════════════════════════════════════════════════
535
+ if (result.verification) {
536
+ const v = result.verification
537
+ if (v.passed) {
538
+ const items = v.checks.map((c) => `${c.name} (${c.durationMs}ms)`)
539
+ out.section('Verified')
540
+ out.list(items, { bullet: '✓' })
541
+ } else {
542
+ out.section('Verification')
543
+ const items = v.checks.map((c) =>
544
+ c.passed ? `✓ ${c.name}` : `✗ ${c.name}${c.error ? ` — ${c.error}` : ''}`
545
+ )
546
+ out.list(items)
547
+ if (v.skippedCount > 0) {
548
+ out.warn(`${v.skippedCount} check(s) skipped (fail-fast)`)
549
+ }
550
+ }
551
+ console.log('')
552
+ }
553
+
532
554
  // ═══════════════════════════════════════════════════════════════════════
533
555
  // NEXT STEPS - Clear call to action
534
556
  // ═══════════════════════════════════════════════════════════════════════
@@ -35,6 +35,7 @@ import { ContextFileGenerator } from './context-generator'
35
35
  import type { SyncDiff } from './diff-generator'
36
36
  import { localStateGenerator } from './local-state-generator'
37
37
  import { type StackDetection, StackDetector } from './stack-detector'
38
+ import { syncVerifier, type VerificationReport } from './sync-verifier'
38
39
 
39
40
  const execAsync = promisify(exec)
40
41
 
@@ -106,6 +107,7 @@ interface SyncResult {
106
107
  contextFiles: string[]
107
108
  aiTools: AIToolResult[]
108
109
  syncMetrics?: SyncMetrics
110
+ verification?: VerificationReport
109
111
  error?: string
110
112
  // Preview mode fields
111
113
  isPreview?: boolean
@@ -246,6 +248,19 @@ class SyncService {
246
248
  await commandInstaller.installGlobalConfig()
247
249
  await commandInstaller.syncCommands()
248
250
 
251
+ // 11. Run verification checks (built-in + custom from config)
252
+ let verification: VerificationReport | undefined
253
+ try {
254
+ const localConfig = await configManager.readConfig(this.projectPath)
255
+ verification = await syncVerifier.verify(
256
+ this.projectPath,
257
+ this.globalPath,
258
+ localConfig?.verification
259
+ )
260
+ } catch {
261
+ // Verification is non-critical — don't fail sync
262
+ }
263
+
249
264
  return {
250
265
  success: true,
251
266
  projectId: this.projectId,
@@ -263,6 +278,7 @@ class SyncService {
263
278
  success: r.success,
264
279
  })),
265
280
  syncMetrics,
281
+ verification,
266
282
  }
267
283
  } catch (error) {
268
284
  return {
@@ -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
  /**