prjct-cli 1.10.0 → 1.11.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 +39 -0
- package/core/__tests__/storage/analysis-storage.test.ts +277 -0
- package/core/commands/analysis.ts +117 -2
- package/core/commands/command-data.ts +29 -0
- package/core/commands/commands.ts +14 -0
- package/core/commands/register.ts +2 -0
- package/core/index.ts +2 -0
- package/core/schemas/analysis.ts +69 -25
- package/core/services/sync-service.ts +35 -0
- package/core/storage/analysis-storage.ts +328 -0
- package/core/storage/index.ts +2 -0
- package/dist/bin/prjct.mjs +956 -510
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.11.0] - 2026-02-09
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- implement sealable analysis with commit-hash signature (PRJ-263) (#153)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## [1.11.0] - 2026-02-08
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
- **Sealable Analysis**: 3-state lifecycle (draft/verified/sealed) with SHA-256 commit-hash signatures (PRJ-263)
|
|
14
|
+
- **Dual Storage**: Re-sync creates drafts without destroying sealed analysis — only sealed feeds task context
|
|
15
|
+
- **Staleness Detection**: Warns when HEAD moves past the sealed commit hash
|
|
16
|
+
- **Seal & Verify Commands**: `prjct seal` locks draft analysis, `prjct verify` checks integrity
|
|
17
|
+
|
|
18
|
+
### Implementation Details
|
|
19
|
+
New `analysis-storage.ts` extends StorageManager with dual storage (draft + sealed). Analysis schema rewritten as Zod schemas with runtime validation. Sync service writes drafts in parallel with existing writes. Canonical JSON representation ensures deterministic SHA-256 signatures.
|
|
20
|
+
|
|
21
|
+
Key changes:
|
|
22
|
+
- `core/schemas/analysis.ts` — Full rewrite: plain interfaces → Zod schemas with `AnalysisStatusSchema`, `AnalysisItemSchema`
|
|
23
|
+
- `core/storage/analysis-storage.ts` — New: dual storage, sealing, verification, staleness detection
|
|
24
|
+
- `core/services/sync-service.ts` — Added `saveDraftAnalysis()` to parallel writes
|
|
25
|
+
- `core/commands/analysis.ts` — Added `seal()` and `verify()` command methods
|
|
26
|
+
- `core/commands/register.ts`, `core/index.ts` — Registered new commands
|
|
27
|
+
|
|
28
|
+
### Test Plan
|
|
29
|
+
|
|
30
|
+
#### For QA
|
|
31
|
+
1. Run `prjct sync` — verify draft analysis is created in storage
|
|
32
|
+
2. Run `prjct seal` — verify analysis is locked with SHA-256 signature
|
|
33
|
+
3. Run `prjct verify` — verify signature matches
|
|
34
|
+
4. Run `prjct sync` again — verify sealed analysis is preserved, new draft created
|
|
35
|
+
5. Make a commit, run `prjct status` — verify staleness detection warns about diverged commits
|
|
36
|
+
|
|
37
|
+
#### For Users
|
|
38
|
+
- **What changed:** Analysis results can now be locked (sealed) so re-syncing doesn't overwrite verified context
|
|
39
|
+
- **How to use:** Run `prjct seal` after reviewing sync results, `prjct verify` to check integrity
|
|
40
|
+
- **Breaking changes:** None — old analysis files parse with `status: 'draft'` default
|
|
41
|
+
|
|
3
42
|
## [1.10.0] - 2026-02-08
|
|
4
43
|
|
|
5
44
|
### Features
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analysis Storage Tests (PRJ-263)
|
|
3
|
+
*
|
|
4
|
+
* Tests for sealable analysis lifecycle:
|
|
5
|
+
* - Schema validation (draft/verified/sealed)
|
|
6
|
+
* - Signature computation and verification
|
|
7
|
+
* - Staleness detection
|
|
8
|
+
* - Draft preservation on re-sync
|
|
9
|
+
* - Backward compatibility
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from 'bun:test'
|
|
13
|
+
import {
|
|
14
|
+
AnalysisItemSchema,
|
|
15
|
+
AnalysisStatusSchema,
|
|
16
|
+
parseAnalysis,
|
|
17
|
+
safeParseAnalysis,
|
|
18
|
+
} from '../../schemas/analysis'
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// Schema Validation
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
describe('AnalysisStatusSchema', () => {
|
|
25
|
+
it('should accept valid statuses', () => {
|
|
26
|
+
expect(AnalysisStatusSchema.parse('draft')).toBe('draft')
|
|
27
|
+
expect(AnalysisStatusSchema.parse('verified')).toBe('verified')
|
|
28
|
+
expect(AnalysisStatusSchema.parse('sealed')).toBe('sealed')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('should reject invalid statuses', () => {
|
|
32
|
+
expect(() => AnalysisStatusSchema.parse('pending')).toThrow()
|
|
33
|
+
expect(() => AnalysisStatusSchema.parse('active')).toThrow()
|
|
34
|
+
expect(() => AnalysisStatusSchema.parse('')).toThrow()
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('AnalysisItemSchema', () => {
|
|
39
|
+
const validDraft = {
|
|
40
|
+
projectId: 'test-project-123',
|
|
41
|
+
languages: ['TypeScript'],
|
|
42
|
+
frameworks: ['Hono'],
|
|
43
|
+
configFiles: ['tsconfig.json'],
|
|
44
|
+
fileCount: 295,
|
|
45
|
+
patterns: [{ name: 'Service pattern', description: 'Classes with dependency injection' }],
|
|
46
|
+
antiPatterns: [],
|
|
47
|
+
analyzedAt: '2026-02-08T20:00:00.000Z',
|
|
48
|
+
status: 'draft' as const,
|
|
49
|
+
commitHash: 'abc1234',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
it('should parse a valid draft analysis', () => {
|
|
53
|
+
const result = AnalysisItemSchema.parse(validDraft)
|
|
54
|
+
expect(result.status).toBe('draft')
|
|
55
|
+
expect(result.commitHash).toBe('abc1234')
|
|
56
|
+
expect(result.projectId).toBe('test-project-123')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('should default status to draft when not provided', () => {
|
|
60
|
+
const { status, ...withoutStatus } = validDraft
|
|
61
|
+
const result = AnalysisItemSchema.parse(withoutStatus)
|
|
62
|
+
expect(result.status).toBe('draft')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('should parse a sealed analysis with signature', () => {
|
|
66
|
+
const sealed = {
|
|
67
|
+
...validDraft,
|
|
68
|
+
status: 'sealed' as const,
|
|
69
|
+
signature: 'sha256-abc123def456',
|
|
70
|
+
sealedAt: '2026-02-08T21:00:00.000Z',
|
|
71
|
+
}
|
|
72
|
+
const result = AnalysisItemSchema.parse(sealed)
|
|
73
|
+
expect(result.status).toBe('sealed')
|
|
74
|
+
expect(result.signature).toBe('sha256-abc123def456')
|
|
75
|
+
expect(result.sealedAt).toBe('2026-02-08T21:00:00.000Z')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should accept optional fields as undefined', () => {
|
|
79
|
+
const minimal = {
|
|
80
|
+
projectId: 'test',
|
|
81
|
+
languages: [],
|
|
82
|
+
frameworks: [],
|
|
83
|
+
configFiles: [],
|
|
84
|
+
fileCount: 0,
|
|
85
|
+
patterns: [],
|
|
86
|
+
antiPatterns: [],
|
|
87
|
+
analyzedAt: '2026-02-08T20:00:00.000Z',
|
|
88
|
+
}
|
|
89
|
+
const result = AnalysisItemSchema.parse(minimal)
|
|
90
|
+
expect(result.status).toBe('draft')
|
|
91
|
+
expect(result.commitHash).toBeUndefined()
|
|
92
|
+
expect(result.signature).toBeUndefined()
|
|
93
|
+
expect(result.sealedAt).toBeUndefined()
|
|
94
|
+
expect(result.modelMetadata).toBeUndefined()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('should reject missing required fields', () => {
|
|
98
|
+
expect(() => AnalysisItemSchema.parse({})).toThrow()
|
|
99
|
+
expect(() => AnalysisItemSchema.parse({ projectId: 'test' })).toThrow()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('parseAnalysis / safeParseAnalysis', () => {
|
|
104
|
+
it('should parse valid data', () => {
|
|
105
|
+
const data = {
|
|
106
|
+
projectId: 'test',
|
|
107
|
+
languages: ['TypeScript'],
|
|
108
|
+
frameworks: [],
|
|
109
|
+
configFiles: [],
|
|
110
|
+
fileCount: 10,
|
|
111
|
+
patterns: [],
|
|
112
|
+
antiPatterns: [],
|
|
113
|
+
analyzedAt: '2026-02-08T20:00:00.000Z',
|
|
114
|
+
}
|
|
115
|
+
const result = parseAnalysis(data)
|
|
116
|
+
expect(result.projectId).toBe('test')
|
|
117
|
+
expect(result.status).toBe('draft')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should return success for safeParseAnalysis with valid data', () => {
|
|
121
|
+
const data = {
|
|
122
|
+
projectId: 'test',
|
|
123
|
+
languages: [],
|
|
124
|
+
frameworks: [],
|
|
125
|
+
configFiles: [],
|
|
126
|
+
fileCount: 0,
|
|
127
|
+
patterns: [],
|
|
128
|
+
antiPatterns: [],
|
|
129
|
+
analyzedAt: '2026-02-08T20:00:00.000Z',
|
|
130
|
+
}
|
|
131
|
+
const result = safeParseAnalysis(data)
|
|
132
|
+
expect(result.success).toBe(true)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should return failure for safeParseAnalysis with invalid data', () => {
|
|
136
|
+
const result = safeParseAnalysis({ invalid: true })
|
|
137
|
+
expect(result.success).toBe(false)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// =============================================================================
|
|
142
|
+
// Staleness Detection (pure function tests)
|
|
143
|
+
// =============================================================================
|
|
144
|
+
|
|
145
|
+
describe('staleness detection', () => {
|
|
146
|
+
// Test checkStaleness method from AnalysisStorage
|
|
147
|
+
it('should detect stale analysis when commits differ', () => {
|
|
148
|
+
const { analysisStorage } = require('../../storage/analysis-storage')
|
|
149
|
+
const result = analysisStorage.checkStaleness('abc1234', 'def5678')
|
|
150
|
+
expect(result.isStale).toBe(true)
|
|
151
|
+
expect(result.sealedCommit).toBe('abc1234')
|
|
152
|
+
expect(result.currentCommit).toBe('def5678')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('should detect fresh analysis when commits match', () => {
|
|
156
|
+
const { analysisStorage } = require('../../storage/analysis-storage')
|
|
157
|
+
const result = analysisStorage.checkStaleness('abc1234', 'abc1234')
|
|
158
|
+
expect(result.isStale).toBe(false)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('should handle null sealed commit', () => {
|
|
162
|
+
const { analysisStorage } = require('../../storage/analysis-storage')
|
|
163
|
+
const result = analysisStorage.checkStaleness(null, 'abc1234')
|
|
164
|
+
expect(result.isStale).toBe(false)
|
|
165
|
+
expect(result.message).toContain('No sealed analysis')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('should handle null current commit', () => {
|
|
169
|
+
const { analysisStorage } = require('../../storage/analysis-storage')
|
|
170
|
+
const result = analysisStorage.checkStaleness('abc1234', null)
|
|
171
|
+
expect(result.isStale).toBe(true)
|
|
172
|
+
expect(result.message).toContain('Cannot determine')
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// =============================================================================
|
|
177
|
+
// Signature Computation (determinism test)
|
|
178
|
+
// =============================================================================
|
|
179
|
+
|
|
180
|
+
describe('signature computation', () => {
|
|
181
|
+
it('should produce deterministic signatures for same input', () => {
|
|
182
|
+
const { createHash } = require('node:crypto')
|
|
183
|
+
|
|
184
|
+
const analysis = {
|
|
185
|
+
projectId: 'test',
|
|
186
|
+
languages: ['TypeScript'],
|
|
187
|
+
frameworks: ['Hono'],
|
|
188
|
+
configFiles: [],
|
|
189
|
+
fileCount: 100,
|
|
190
|
+
patterns: [],
|
|
191
|
+
antiPatterns: [],
|
|
192
|
+
analyzedAt: '2026-02-08T20:00:00.000Z',
|
|
193
|
+
commitHash: 'abc1234',
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const canonical = {
|
|
197
|
+
projectId: analysis.projectId,
|
|
198
|
+
languages: analysis.languages,
|
|
199
|
+
frameworks: analysis.frameworks,
|
|
200
|
+
packageManager: undefined,
|
|
201
|
+
sourceDir: undefined,
|
|
202
|
+
testDir: undefined,
|
|
203
|
+
configFiles: analysis.configFiles,
|
|
204
|
+
fileCount: analysis.fileCount,
|
|
205
|
+
patterns: analysis.patterns,
|
|
206
|
+
antiPatterns: analysis.antiPatterns,
|
|
207
|
+
analyzedAt: analysis.analyzedAt,
|
|
208
|
+
commitHash: analysis.commitHash,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const sig1 = createHash('sha256').update(JSON.stringify(canonical)).digest('hex')
|
|
212
|
+
const sig2 = createHash('sha256').update(JSON.stringify(canonical)).digest('hex')
|
|
213
|
+
|
|
214
|
+
expect(sig1).toBe(sig2)
|
|
215
|
+
expect(sig1).toHaveLength(64) // SHA-256 hex = 64 chars
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('should produce different signatures for different inputs', () => {
|
|
219
|
+
const { createHash } = require('node:crypto')
|
|
220
|
+
|
|
221
|
+
const data1 = JSON.stringify({ projectId: 'a', fileCount: 1 })
|
|
222
|
+
const data2 = JSON.stringify({ projectId: 'b', fileCount: 2 })
|
|
223
|
+
|
|
224
|
+
const sig1 = createHash('sha256').update(data1).digest('hex')
|
|
225
|
+
const sig2 = createHash('sha256').update(data2).digest('hex')
|
|
226
|
+
|
|
227
|
+
expect(sig1).not.toBe(sig2)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// =============================================================================
|
|
232
|
+
// Backward Compatibility
|
|
233
|
+
// =============================================================================
|
|
234
|
+
|
|
235
|
+
describe('backward compatibility', () => {
|
|
236
|
+
it('should parse old analysis.json without seal fields', () => {
|
|
237
|
+
const oldFormat = {
|
|
238
|
+
projectId: 'old-project',
|
|
239
|
+
languages: ['JavaScript'],
|
|
240
|
+
frameworks: ['Express'],
|
|
241
|
+
configFiles: ['package.json'],
|
|
242
|
+
fileCount: 50,
|
|
243
|
+
patterns: [],
|
|
244
|
+
antiPatterns: [],
|
|
245
|
+
analyzedAt: '2025-12-01T00:00:00.000Z',
|
|
246
|
+
// No status, commitHash, signature, sealedAt
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const result = AnalysisItemSchema.parse(oldFormat)
|
|
250
|
+
expect(result.status).toBe('draft') // Default
|
|
251
|
+
expect(result.commitHash).toBeUndefined()
|
|
252
|
+
expect(result.signature).toBeUndefined()
|
|
253
|
+
expect(result.sealedAt).toBeUndefined()
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('should parse old analysis with modelMetadata but no seal fields', () => {
|
|
257
|
+
const oldWithModel = {
|
|
258
|
+
projectId: 'old-project',
|
|
259
|
+
languages: ['TypeScript'],
|
|
260
|
+
frameworks: [],
|
|
261
|
+
configFiles: [],
|
|
262
|
+
fileCount: 10,
|
|
263
|
+
patterns: [],
|
|
264
|
+
antiPatterns: [],
|
|
265
|
+
analyzedAt: '2026-01-15T00:00:00.000Z',
|
|
266
|
+
modelMetadata: {
|
|
267
|
+
provider: 'claude',
|
|
268
|
+
model: 'sonnet',
|
|
269
|
+
recordedAt: '2026-01-15T00:00:00.000Z',
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const result = AnalysisItemSchema.parse(oldWithModel)
|
|
274
|
+
expect(result.status).toBe('draft')
|
|
275
|
+
expect(result.modelMetadata?.provider).toBe('claude')
|
|
276
|
+
})
|
|
277
|
+
})
|
|
@@ -12,6 +12,7 @@ import commandInstaller from '../infrastructure/command-installer'
|
|
|
12
12
|
import { formatCost } from '../schemas/metrics'
|
|
13
13
|
import { createStalenessChecker, memoryService, syncService } from '../services'
|
|
14
14
|
import { formatDiffPreview, formatFullDiff, generateSyncDiff } from '../services/diff-generator'
|
|
15
|
+
import { analysisStorage } from '../storage/analysis-storage'
|
|
15
16
|
import { metricsStorage } from '../storage/metrics-storage'
|
|
16
17
|
import type { AnalyzeOptions, CommandResult, ProjectContext } from '../types'
|
|
17
18
|
import { getErrorMessage } from '../types/fs'
|
|
@@ -805,6 +806,9 @@ export class AnalysisCommands extends PrjctCommandsBase {
|
|
|
805
806
|
// Get session info
|
|
806
807
|
const sessionInfo = await checker.getSessionInfo(projectId)
|
|
807
808
|
|
|
809
|
+
// Get analysis status (PRJ-263)
|
|
810
|
+
const analysisStatus = await analysisStorage.getStatus(projectId)
|
|
811
|
+
|
|
808
812
|
// JSON output mode
|
|
809
813
|
if (options.json) {
|
|
810
814
|
console.log(
|
|
@@ -812,9 +816,13 @@ export class AnalysisCommands extends PrjctCommandsBase {
|
|
|
812
816
|
success: true,
|
|
813
817
|
...status,
|
|
814
818
|
session: sessionInfo,
|
|
819
|
+
analysis: analysisStatus,
|
|
815
820
|
})
|
|
816
821
|
)
|
|
817
|
-
return {
|
|
822
|
+
return {
|
|
823
|
+
success: true,
|
|
824
|
+
data: { ...status, session: sessionInfo, analysis: analysisStatus },
|
|
825
|
+
}
|
|
818
826
|
}
|
|
819
827
|
|
|
820
828
|
// Human-readable output
|
|
@@ -822,9 +830,22 @@ export class AnalysisCommands extends PrjctCommandsBase {
|
|
|
822
830
|
console.log(checker.formatStatus(status))
|
|
823
831
|
console.log('')
|
|
824
832
|
console.log(checker.formatSessionInfo(sessionInfo))
|
|
833
|
+
|
|
834
|
+
// Show analysis status (PRJ-263)
|
|
835
|
+
if (analysisStatus.hasSealed || analysisStatus.hasDraft) {
|
|
836
|
+
console.log('')
|
|
837
|
+
console.log('Analysis:')
|
|
838
|
+
if (analysisStatus.hasSealed) {
|
|
839
|
+
console.log(` Sealed: ${analysisStatus.sealedCommit} (${analysisStatus.sealedAt})`)
|
|
840
|
+
}
|
|
841
|
+
if (analysisStatus.hasDraft) {
|
|
842
|
+
console.log(` Draft: ${analysisStatus.draftCommit} (pending seal)`)
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
825
846
|
console.log('')
|
|
826
847
|
|
|
827
|
-
return { success: true, data: { ...status, session: sessionInfo } }
|
|
848
|
+
return { success: true, data: { ...status, session: sessionInfo, analysis: analysisStatus } }
|
|
828
849
|
} catch (error) {
|
|
829
850
|
const errMsg = getErrorMessage(error)
|
|
830
851
|
if (options.json) {
|
|
@@ -836,6 +857,100 @@ export class AnalysisCommands extends PrjctCommandsBase {
|
|
|
836
857
|
}
|
|
837
858
|
}
|
|
838
859
|
|
|
860
|
+
/**
|
|
861
|
+
* prjct seal - Seal the current draft analysis (PRJ-263)
|
|
862
|
+
*
|
|
863
|
+
* Locks the current draft with a SHA-256 signature.
|
|
864
|
+
* Only sealed analysis feeds task context.
|
|
865
|
+
*/
|
|
866
|
+
async seal(
|
|
867
|
+
projectPath: string = process.cwd(),
|
|
868
|
+
options: { json?: boolean } = {}
|
|
869
|
+
): Promise<CommandResult> {
|
|
870
|
+
try {
|
|
871
|
+
const initResult = await this.ensureProjectInit(projectPath)
|
|
872
|
+
if (!initResult.success) return initResult
|
|
873
|
+
|
|
874
|
+
const projectId = await configManager.getProjectId(projectPath)
|
|
875
|
+
if (!projectId) {
|
|
876
|
+
if (options.json) {
|
|
877
|
+
console.log(JSON.stringify({ success: false, error: 'No project ID found' }))
|
|
878
|
+
}
|
|
879
|
+
return { success: false, error: 'No project ID found' }
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const result = await analysisStorage.seal(projectId)
|
|
883
|
+
|
|
884
|
+
if (options.json) {
|
|
885
|
+
console.log(
|
|
886
|
+
JSON.stringify({
|
|
887
|
+
success: result.success,
|
|
888
|
+
signature: result.signature,
|
|
889
|
+
error: result.error,
|
|
890
|
+
})
|
|
891
|
+
)
|
|
892
|
+
return { success: result.success, error: result.error }
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
if (!result.success) {
|
|
896
|
+
out.fail(result.error || 'Seal failed')
|
|
897
|
+
return { success: false, error: result.error }
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
out.done('Analysis sealed')
|
|
901
|
+
console.log(` Signature: ${result.signature?.substring(0, 16)}...`)
|
|
902
|
+
console.log('')
|
|
903
|
+
|
|
904
|
+
return { success: true, data: { signature: result.signature } }
|
|
905
|
+
} catch (error) {
|
|
906
|
+
const errMsg = getErrorMessage(error)
|
|
907
|
+
if (options.json) {
|
|
908
|
+
console.log(JSON.stringify({ success: false, error: errMsg }))
|
|
909
|
+
} else {
|
|
910
|
+
out.fail(errMsg)
|
|
911
|
+
}
|
|
912
|
+
return { success: false, error: errMsg }
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* prjct verify - Verify integrity of sealed analysis (PRJ-263)
|
|
918
|
+
*/
|
|
919
|
+
async verify(
|
|
920
|
+
projectPath: string = process.cwd(),
|
|
921
|
+
options: { json?: boolean } = {}
|
|
922
|
+
): Promise<CommandResult> {
|
|
923
|
+
try {
|
|
924
|
+
const initResult = await this.ensureProjectInit(projectPath)
|
|
925
|
+
if (!initResult.success) return initResult
|
|
926
|
+
|
|
927
|
+
const projectId = await configManager.getProjectId(projectPath)
|
|
928
|
+
if (!projectId) {
|
|
929
|
+
return { success: false, error: 'No project ID found' }
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const result = await analysisStorage.verify(projectId)
|
|
933
|
+
|
|
934
|
+
if (options.json) {
|
|
935
|
+
console.log(JSON.stringify(result))
|
|
936
|
+
return { success: result.valid }
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if (result.valid) {
|
|
940
|
+
out.done(result.message)
|
|
941
|
+
} else {
|
|
942
|
+
out.fail(result.message)
|
|
943
|
+
}
|
|
944
|
+
console.log('')
|
|
945
|
+
|
|
946
|
+
return { success: result.valid, data: result }
|
|
947
|
+
} catch (error) {
|
|
948
|
+
const errMsg = getErrorMessage(error)
|
|
949
|
+
out.fail(errMsg)
|
|
950
|
+
return { success: false, error: errMsg }
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
839
954
|
/**
|
|
840
955
|
* Get session activity stats from today's events
|
|
841
956
|
* @see PRJ-89
|
|
@@ -218,6 +218,35 @@ export const COMMANDS: CommandMeta[] = [
|
|
|
218
218
|
'Configurable staleness thresholds',
|
|
219
219
|
],
|
|
220
220
|
},
|
|
221
|
+
{
|
|
222
|
+
name: 'seal',
|
|
223
|
+
group: 'core',
|
|
224
|
+
description: 'Seal the current draft analysis with a commit-hash signature',
|
|
225
|
+
usage: { claude: '/p:seal', terminal: 'prjct seal' },
|
|
226
|
+
params: '[--json]',
|
|
227
|
+
implemented: true,
|
|
228
|
+
hasTemplate: false,
|
|
229
|
+
requiresProject: true,
|
|
230
|
+
features: [
|
|
231
|
+
'Locks draft analysis with SHA-256 signature',
|
|
232
|
+
'Only sealed analysis feeds task context',
|
|
233
|
+
'Detects staleness when HEAD moves past sealed commit',
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: 'verify',
|
|
238
|
+
group: 'core',
|
|
239
|
+
description: 'Verify integrity of sealed analysis',
|
|
240
|
+
usage: { claude: '/p:verify', terminal: 'prjct verify' },
|
|
241
|
+
params: '[--json]',
|
|
242
|
+
implemented: true,
|
|
243
|
+
hasTemplate: false,
|
|
244
|
+
requiresProject: true,
|
|
245
|
+
features: [
|
|
246
|
+
'Recomputes SHA-256 signature and compares',
|
|
247
|
+
'Detects if sealed analysis was modified',
|
|
248
|
+
],
|
|
249
|
+
},
|
|
221
250
|
{
|
|
222
251
|
name: 'help',
|
|
223
252
|
group: 'core',
|
|
@@ -218,6 +218,20 @@ class PrjctCommands {
|
|
|
218
218
|
return this.analysis.status(projectPath, options)
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
async seal(
|
|
222
|
+
projectPath: string = process.cwd(),
|
|
223
|
+
options: { json?: boolean } = {}
|
|
224
|
+
): Promise<CommandResult> {
|
|
225
|
+
return this.analysis.seal(projectPath, options)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async verify(
|
|
229
|
+
projectPath: string = process.cwd(),
|
|
230
|
+
options: { json?: boolean } = {}
|
|
231
|
+
): Promise<CommandResult> {
|
|
232
|
+
return this.analysis.verify(projectPath, options)
|
|
233
|
+
}
|
|
234
|
+
|
|
221
235
|
// ========== Context Commands ==========
|
|
222
236
|
|
|
223
237
|
async context(
|
|
@@ -91,6 +91,8 @@ export function registerAllCommands(): void {
|
|
|
91
91
|
commandRegistry.registerMethod('sync', analysis, 'sync', getMeta('sync'))
|
|
92
92
|
commandRegistry.registerMethod('stats', analysis, 'stats', getMeta('stats'))
|
|
93
93
|
commandRegistry.registerMethod('status', analysis, 'status', getMeta('status'))
|
|
94
|
+
commandRegistry.registerMethod('seal', analysis, 'seal', getMeta('seal'))
|
|
95
|
+
commandRegistry.registerMethod('verify', analysis, 'verify', getMeta('verify'))
|
|
94
96
|
|
|
95
97
|
// Setup commands
|
|
96
98
|
commandRegistry.registerMethod('start', setup, 'start', getMeta('start'))
|
package/core/index.ts
CHANGED
|
@@ -170,6 +170,8 @@ async function main(): Promise<void> {
|
|
|
170
170
|
json: options.json === true,
|
|
171
171
|
package: options.package ? String(options.package) : undefined,
|
|
172
172
|
}),
|
|
173
|
+
seal: () => commands.seal(process.cwd(), { json: options.json === true }),
|
|
174
|
+
verify: () => commands.verify(process.cwd(), { json: options.json === true }),
|
|
173
175
|
start: () => commands.start(),
|
|
174
176
|
// Context (for Claude templates)
|
|
175
177
|
context: (p) => commands.context(p),
|
package/core/schemas/analysis.ts
CHANGED
|
@@ -2,37 +2,80 @@
|
|
|
2
2
|
* Analysis Schema
|
|
3
3
|
*
|
|
4
4
|
* Defines the structure for analysis.json - repository analysis.
|
|
5
|
+
* Supports a 3-state lifecycle: DRAFT → VERIFIED → SEALED (PRJ-263).
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
import { ModelMetadataSchema } from './model'
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
location?: string
|
|
13
|
-
}
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// Zod Schemas - Source of Truth
|
|
13
|
+
// =============================================================================
|
|
14
14
|
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
export const AnalysisStatusSchema = z.enum(['draft', 'verified', 'sealed'])
|
|
16
|
+
|
|
17
|
+
export const CodePatternSchema = z.object({
|
|
18
|
+
name: z.string(),
|
|
19
|
+
description: z.string(),
|
|
20
|
+
location: z.string().optional(),
|
|
21
|
+
})
|
|
20
22
|
|
|
21
|
-
export
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
23
|
+
export const AntiPatternSchema = z.object({
|
|
24
|
+
issue: z.string(),
|
|
25
|
+
file: z.string(),
|
|
26
|
+
suggestion: z.string(),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export const AnalysisItemSchema = z.object({
|
|
30
|
+
projectId: z.string(),
|
|
31
|
+
languages: z.array(z.string()),
|
|
32
|
+
frameworks: z.array(z.string()),
|
|
33
|
+
packageManager: z.string().optional(),
|
|
34
|
+
sourceDir: z.string().optional(),
|
|
35
|
+
testDir: z.string().optional(),
|
|
36
|
+
configFiles: z.array(z.string()),
|
|
37
|
+
fileCount: z.number(),
|
|
38
|
+
patterns: z.array(CodePatternSchema),
|
|
39
|
+
antiPatterns: z.array(AntiPatternSchema),
|
|
40
|
+
analyzedAt: z.string(), // ISO8601
|
|
33
41
|
/** Which AI model was used for this analysis (PRJ-265) */
|
|
34
|
-
modelMetadata
|
|
35
|
-
|
|
42
|
+
modelMetadata: ModelMetadataSchema.optional(),
|
|
43
|
+
|
|
44
|
+
// Sealable analysis fields (PRJ-263)
|
|
45
|
+
/** Lifecycle status: draft (regenerable), verified (confirmed correct), sealed (locked) */
|
|
46
|
+
status: AnalysisStatusSchema.default('draft'),
|
|
47
|
+
/** Git commit hash at the time of analysis */
|
|
48
|
+
commitHash: z.string().optional(),
|
|
49
|
+
/** SHA-256 signature of analysis content + commit hash */
|
|
50
|
+
signature: z.string().optional(),
|
|
51
|
+
/** When the analysis was sealed */
|
|
52
|
+
sealedAt: z.string().optional(), // ISO8601
|
|
53
|
+
/** When the analysis was verified */
|
|
54
|
+
verifiedAt: z.string().optional(), // ISO8601
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// =============================================================================
|
|
58
|
+
// Inferred Types - Backward Compatible
|
|
59
|
+
// =============================================================================
|
|
60
|
+
|
|
61
|
+
export type AnalysisStatus = z.infer<typeof AnalysisStatusSchema>
|
|
62
|
+
export type CodePattern = z.infer<typeof CodePatternSchema>
|
|
63
|
+
export type AntiPattern = z.infer<typeof AntiPatternSchema>
|
|
64
|
+
/** Use z.input so optional fields with defaults (like status) remain optional in creation */
|
|
65
|
+
export type AnalysisSchema = z.input<typeof AnalysisItemSchema>
|
|
66
|
+
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// Validation Helpers
|
|
69
|
+
// =============================================================================
|
|
70
|
+
|
|
71
|
+
/** Parse and validate analysis.json content */
|
|
72
|
+
export const parseAnalysis = (data: unknown): z.infer<typeof AnalysisItemSchema> =>
|
|
73
|
+
AnalysisItemSchema.parse(data)
|
|
74
|
+
export const safeParseAnalysis = (data: unknown) => AnalysisItemSchema.safeParse(data)
|
|
75
|
+
|
|
76
|
+
// =============================================================================
|
|
77
|
+
// Defaults
|
|
78
|
+
// =============================================================================
|
|
36
79
|
|
|
37
80
|
export const DEFAULT_ANALYSIS: Omit<AnalysisSchema, 'projectId'> = {
|
|
38
81
|
languages: [],
|
|
@@ -42,4 +85,5 @@ export const DEFAULT_ANALYSIS: Omit<AnalysisSchema, 'projectId'> = {
|
|
|
42
85
|
patterns: [],
|
|
43
86
|
antiPatterns: [],
|
|
44
87
|
analyzedAt: new Date().toISOString(),
|
|
88
|
+
status: 'draft',
|
|
45
89
|
}
|