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 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 { success: true, data: { ...status, session: sessionInfo } }
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),
@@ -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 type { ModelMetadata } from './model'
8
+ import { z } from 'zod'
9
+ import { ModelMetadataSchema } from './model'
8
10
 
9
- export interface CodePattern {
10
- name: string
11
- description: string
12
- location?: string
13
- }
11
+ // =============================================================================
12
+ // Zod Schemas - Source of Truth
13
+ // =============================================================================
14
14
 
15
- export interface AntiPattern {
16
- issue: string
17
- file: string
18
- suggestion: string
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 interface AnalysisSchema {
22
- projectId: string
23
- languages: string[]
24
- frameworks: string[]
25
- packageManager?: string
26
- sourceDir?: string
27
- testDir?: string
28
- configFiles: string[]
29
- fileCount: number
30
- patterns: CodePattern[]
31
- antiPatterns: AntiPattern[]
32
- analyzedAt: string // ISO8601
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?: 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
  }