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 +55 -0
- package/core/commands/analysis.ts +22 -0
- package/core/services/sync-service.ts +16 -0
- package/core/services/sync-verifier.ts +273 -0
- package/core/types/config.ts +14 -0
- package/dist/bin/prjct.mjs +550 -324
- package/package.json +1 -1
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
|
package/core/types/config.ts
CHANGED
|
@@ -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
|
/**
|