sequant 1.12.0 → 1.13.1
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/README.md +10 -8
- package/dist/bin/cli.js +19 -9
- package/dist/src/commands/doctor.js +42 -20
- package/dist/src/commands/init.js +152 -65
- package/dist/src/commands/logs.js +7 -6
- package/dist/src/commands/run.d.ts +13 -1
- package/dist/src/commands/run.js +122 -32
- package/dist/src/commands/stats.js +67 -48
- package/dist/src/commands/status.js +30 -12
- package/dist/src/commands/sync.d.ts +28 -0
- package/dist/src/commands/sync.js +102 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +4 -0
- package/dist/src/lib/cli-ui.d.ts +196 -0
- package/dist/src/lib/cli-ui.js +544 -0
- package/dist/src/lib/content-analyzer.d.ts +89 -0
- package/dist/src/lib/content-analyzer.js +437 -0
- package/dist/src/lib/phase-signal.d.ts +94 -0
- package/dist/src/lib/phase-signal.js +171 -0
- package/dist/src/lib/phase-spinner.d.ts +146 -0
- package/dist/src/lib/phase-spinner.js +255 -0
- package/dist/src/lib/solve-comment-parser.d.ts +84 -0
- package/dist/src/lib/solve-comment-parser.js +200 -0
- package/dist/src/lib/stack-config.d.ts +51 -0
- package/dist/src/lib/stack-config.js +77 -0
- package/dist/src/lib/stacks.d.ts +52 -0
- package/dist/src/lib/stacks.js +173 -0
- package/dist/src/lib/templates.d.ts +2 -0
- package/dist/src/lib/templates.js +9 -2
- package/dist/src/lib/upstream/assessment.d.ts +70 -0
- package/dist/src/lib/upstream/assessment.js +385 -0
- package/dist/src/lib/upstream/index.d.ts +11 -0
- package/dist/src/lib/upstream/index.js +14 -0
- package/dist/src/lib/upstream/issues.d.ts +38 -0
- package/dist/src/lib/upstream/issues.js +267 -0
- package/dist/src/lib/upstream/relevance.d.ts +50 -0
- package/dist/src/lib/upstream/relevance.js +209 -0
- package/dist/src/lib/upstream/report.d.ts +29 -0
- package/dist/src/lib/upstream/report.js +391 -0
- package/dist/src/lib/upstream/types.d.ts +207 -0
- package/dist/src/lib/upstream/types.js +5 -0
- package/dist/src/lib/workflow/log-writer.d.ts +1 -1
- package/dist/src/lib/workflow/metrics-schema.d.ts +3 -3
- package/dist/src/lib/workflow/qa-cache.d.ts +199 -0
- package/dist/src/lib/workflow/qa-cache.js +440 -0
- package/dist/src/lib/workflow/run-log-schema.d.ts +34 -6
- package/dist/src/lib/workflow/run-log-schema.js +12 -1
- package/dist/src/lib/workflow/state-schema.d.ts +4 -4
- package/dist/src/lib/workflow/types.d.ts +4 -0
- package/package.json +6 -1
- package/templates/skills/qa/scripts/quality-checks.sh +509 -53
- package/templates/skills/solve/SKILL.md +375 -83
- package/templates/skills/spec/SKILL.md +107 -5
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QA Cache Module - Caches expensive QA check results to skip unchanged checks on re-run
|
|
3
|
+
*
|
|
4
|
+
* Provides caching for type safety, security, and test checks keyed by:
|
|
5
|
+
* - File content hash (git diff hash)
|
|
6
|
+
* - Check type
|
|
7
|
+
* - Configuration hash
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { QACache } from './qa-cache';
|
|
12
|
+
*
|
|
13
|
+
* const cache = new QACache();
|
|
14
|
+
*
|
|
15
|
+
* // Check cache before running expensive check
|
|
16
|
+
* const cached = await cache.get('security');
|
|
17
|
+
* if (cached && !cached.isStale) {
|
|
18
|
+
* console.log('Using cached security scan results');
|
|
19
|
+
* return cached.result;
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* // Run check and cache result
|
|
23
|
+
* const result = await runSecurityScan();
|
|
24
|
+
* await cache.set('security', result);
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
import { z } from "zod";
|
|
28
|
+
/**
|
|
29
|
+
* Check types that can be cached
|
|
30
|
+
*/
|
|
31
|
+
export declare const CHECK_TYPES: readonly ["type-safety", "deleted-tests", "scope", "size", "security", "semgrep", "build", "tests"];
|
|
32
|
+
export type CheckType = (typeof CHECK_TYPES)[number];
|
|
33
|
+
/**
|
|
34
|
+
* Schema for a single cached check result
|
|
35
|
+
*/
|
|
36
|
+
export declare const CachedCheckResultSchema: z.ZodObject<{
|
|
37
|
+
checkType: z.ZodEnum<{
|
|
38
|
+
build: "build";
|
|
39
|
+
security: "security";
|
|
40
|
+
semgrep: "semgrep";
|
|
41
|
+
"type-safety": "type-safety";
|
|
42
|
+
"deleted-tests": "deleted-tests";
|
|
43
|
+
scope: "scope";
|
|
44
|
+
size: "size";
|
|
45
|
+
tests: "tests";
|
|
46
|
+
}>;
|
|
47
|
+
diffHash: z.ZodString;
|
|
48
|
+
configHash: z.ZodString;
|
|
49
|
+
cachedAt: z.ZodString;
|
|
50
|
+
ttl: z.ZodNumber;
|
|
51
|
+
result: z.ZodObject<{
|
|
52
|
+
passed: z.ZodBoolean;
|
|
53
|
+
message: z.ZodString;
|
|
54
|
+
details: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
55
|
+
}, z.core.$strip>;
|
|
56
|
+
}, z.core.$strip>;
|
|
57
|
+
export type CachedCheckResult = z.infer<typeof CachedCheckResultSchema>;
|
|
58
|
+
/**
|
|
59
|
+
* Schema for the complete cache file
|
|
60
|
+
*/
|
|
61
|
+
export declare const QACacheSchema: z.ZodObject<{
|
|
62
|
+
version: z.ZodLiteral<1>;
|
|
63
|
+
lastUpdated: z.ZodString;
|
|
64
|
+
checks: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
65
|
+
checkType: z.ZodEnum<{
|
|
66
|
+
build: "build";
|
|
67
|
+
security: "security";
|
|
68
|
+
semgrep: "semgrep";
|
|
69
|
+
"type-safety": "type-safety";
|
|
70
|
+
"deleted-tests": "deleted-tests";
|
|
71
|
+
scope: "scope";
|
|
72
|
+
size: "size";
|
|
73
|
+
tests: "tests";
|
|
74
|
+
}>;
|
|
75
|
+
diffHash: z.ZodString;
|
|
76
|
+
configHash: z.ZodString;
|
|
77
|
+
cachedAt: z.ZodString;
|
|
78
|
+
ttl: z.ZodNumber;
|
|
79
|
+
result: z.ZodObject<{
|
|
80
|
+
passed: z.ZodBoolean;
|
|
81
|
+
message: z.ZodString;
|
|
82
|
+
details: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
83
|
+
}, z.core.$strip>;
|
|
84
|
+
}, z.core.$strip>>;
|
|
85
|
+
}, z.core.$strip>;
|
|
86
|
+
export type QACacheState = z.infer<typeof QACacheSchema>;
|
|
87
|
+
/**
|
|
88
|
+
* Cache configuration options
|
|
89
|
+
*/
|
|
90
|
+
export interface QACacheOptions {
|
|
91
|
+
/** Path to cache directory (default: .sequant/.cache/qa/) */
|
|
92
|
+
cacheDir?: string;
|
|
93
|
+
/** Default TTL in milliseconds (default: 1 hour) */
|
|
94
|
+
defaultTtl?: number;
|
|
95
|
+
/** Enable verbose logging */
|
|
96
|
+
verbose?: boolean;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Result of cache lookup
|
|
100
|
+
*/
|
|
101
|
+
export interface CacheLookupResult {
|
|
102
|
+
/** Whether cache hit occurred */
|
|
103
|
+
hit: boolean;
|
|
104
|
+
/** Whether cached result is stale (expired TTL) */
|
|
105
|
+
isStale: boolean;
|
|
106
|
+
/** The cached result (if hit) */
|
|
107
|
+
result?: CachedCheckResult["result"];
|
|
108
|
+
/** Reason for cache miss */
|
|
109
|
+
missReason?: "not-found" | "hash-mismatch" | "expired" | "corrupted";
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* QA Cache Manager
|
|
113
|
+
*
|
|
114
|
+
* Manages caching of expensive QA check results to improve re-run performance.
|
|
115
|
+
*/
|
|
116
|
+
export declare class QACache {
|
|
117
|
+
private cacheDir;
|
|
118
|
+
private defaultTtl;
|
|
119
|
+
private verbose;
|
|
120
|
+
private cachedState;
|
|
121
|
+
constructor(options?: QACacheOptions);
|
|
122
|
+
/**
|
|
123
|
+
* Get the cache file path
|
|
124
|
+
*/
|
|
125
|
+
getCachePath(): string;
|
|
126
|
+
/**
|
|
127
|
+
* Compute hash of git diff between main and HEAD
|
|
128
|
+
*
|
|
129
|
+
* This provides a content-based cache key that changes when code changes.
|
|
130
|
+
*/
|
|
131
|
+
computeDiffHash(): string;
|
|
132
|
+
/**
|
|
133
|
+
* Compute hash of configuration files that affect checks
|
|
134
|
+
*/
|
|
135
|
+
computeConfigHash(checkType: CheckType): string;
|
|
136
|
+
/**
|
|
137
|
+
* Get configuration files that affect a specific check type
|
|
138
|
+
*/
|
|
139
|
+
private getConfigFilesForCheck;
|
|
140
|
+
/**
|
|
141
|
+
* Check if global invalidation files have changed
|
|
142
|
+
*/
|
|
143
|
+
checkGlobalInvalidation(): Promise<boolean>;
|
|
144
|
+
/**
|
|
145
|
+
* Check if files matching check-specific patterns have changed
|
|
146
|
+
*/
|
|
147
|
+
checkTypeSpecificInvalidation(checkType: CheckType): Promise<boolean>;
|
|
148
|
+
/**
|
|
149
|
+
* Read the current cache state
|
|
150
|
+
*/
|
|
151
|
+
getState(): Promise<QACacheState>;
|
|
152
|
+
/**
|
|
153
|
+
* Write cache state to disk
|
|
154
|
+
*/
|
|
155
|
+
saveState(state: QACacheState): Promise<void>;
|
|
156
|
+
/**
|
|
157
|
+
* Get cached result for a check type
|
|
158
|
+
*/
|
|
159
|
+
get(checkType: CheckType): Promise<CacheLookupResult>;
|
|
160
|
+
/**
|
|
161
|
+
* Cache a check result
|
|
162
|
+
*/
|
|
163
|
+
set(checkType: CheckType, result: CachedCheckResult["result"], ttl?: number): Promise<void>;
|
|
164
|
+
/**
|
|
165
|
+
* Clear cache for a specific check type
|
|
166
|
+
*/
|
|
167
|
+
clear(checkType: CheckType): Promise<void>;
|
|
168
|
+
/**
|
|
169
|
+
* Clear all cached results
|
|
170
|
+
*/
|
|
171
|
+
clearAll(): Promise<void>;
|
|
172
|
+
/**
|
|
173
|
+
* Get cache status for all check types
|
|
174
|
+
*/
|
|
175
|
+
getStatus(): Promise<Record<CheckType, {
|
|
176
|
+
hit: boolean;
|
|
177
|
+
missReason?: string;
|
|
178
|
+
}>>;
|
|
179
|
+
/**
|
|
180
|
+
* Create an empty cache state
|
|
181
|
+
*/
|
|
182
|
+
private createEmptyState;
|
|
183
|
+
/**
|
|
184
|
+
* Log a message if verbose mode is enabled
|
|
185
|
+
*/
|
|
186
|
+
private log;
|
|
187
|
+
/**
|
|
188
|
+
* Clear the in-memory cache (forces re-read on next access)
|
|
189
|
+
*/
|
|
190
|
+
clearMemoryCache(): void;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Get the default QA cache instance
|
|
194
|
+
*/
|
|
195
|
+
export declare function getQACache(options?: QACacheOptions): QACache;
|
|
196
|
+
/**
|
|
197
|
+
* Reset the default cache instance (for testing)
|
|
198
|
+
*/
|
|
199
|
+
export declare function resetQACache(): void;
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QA Cache Module - Caches expensive QA check results to skip unchanged checks on re-run
|
|
3
|
+
*
|
|
4
|
+
* Provides caching for type safety, security, and test checks keyed by:
|
|
5
|
+
* - File content hash (git diff hash)
|
|
6
|
+
* - Check type
|
|
7
|
+
* - Configuration hash
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { QACache } from './qa-cache';
|
|
12
|
+
*
|
|
13
|
+
* const cache = new QACache();
|
|
14
|
+
*
|
|
15
|
+
* // Check cache before running expensive check
|
|
16
|
+
* const cached = await cache.get('security');
|
|
17
|
+
* if (cached && !cached.isStale) {
|
|
18
|
+
* console.log('Using cached security scan results');
|
|
19
|
+
* return cached.result;
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* // Run check and cache result
|
|
23
|
+
* const result = await runSecurityScan();
|
|
24
|
+
* await cache.set('security', result);
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
import * as fs from "fs";
|
|
28
|
+
import * as path from "path";
|
|
29
|
+
import * as crypto from "crypto";
|
|
30
|
+
import { execSync } from "child_process";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
/**
|
|
33
|
+
* Check types that can be cached
|
|
34
|
+
*/
|
|
35
|
+
export const CHECK_TYPES = [
|
|
36
|
+
"type-safety",
|
|
37
|
+
"deleted-tests",
|
|
38
|
+
"scope",
|
|
39
|
+
"size",
|
|
40
|
+
"security",
|
|
41
|
+
"semgrep",
|
|
42
|
+
"build",
|
|
43
|
+
"tests",
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* Schema for a single cached check result
|
|
47
|
+
*/
|
|
48
|
+
export const CachedCheckResultSchema = z.object({
|
|
49
|
+
/** Type of check */
|
|
50
|
+
checkType: z.enum(CHECK_TYPES),
|
|
51
|
+
/** Git diff hash when check was run */
|
|
52
|
+
diffHash: z.string(),
|
|
53
|
+
/** Config hash when check was run */
|
|
54
|
+
configHash: z.string(),
|
|
55
|
+
/** Timestamp when cached */
|
|
56
|
+
cachedAt: z.string().datetime(),
|
|
57
|
+
/** Time-to-live in milliseconds */
|
|
58
|
+
ttl: z.number().positive(),
|
|
59
|
+
/** The actual check result */
|
|
60
|
+
result: z.object({
|
|
61
|
+
/** Whether the check passed */
|
|
62
|
+
passed: z.boolean(),
|
|
63
|
+
/** Summary message */
|
|
64
|
+
message: z.string(),
|
|
65
|
+
/** Optional details (counts, warnings, etc.) */
|
|
66
|
+
details: z.record(z.string(), z.unknown()).optional(),
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
/**
|
|
70
|
+
* Schema for the complete cache file
|
|
71
|
+
*/
|
|
72
|
+
export const QACacheSchema = z.object({
|
|
73
|
+
/** Cache version for backwards compatibility */
|
|
74
|
+
version: z.literal(1),
|
|
75
|
+
/** When the cache was last updated */
|
|
76
|
+
lastUpdated: z.string().datetime(),
|
|
77
|
+
/** Cached check results keyed by check type */
|
|
78
|
+
checks: z.record(z.string(), CachedCheckResultSchema),
|
|
79
|
+
});
|
|
80
|
+
/**
|
|
81
|
+
* Default TTL: 1 hour
|
|
82
|
+
*/
|
|
83
|
+
const DEFAULT_TTL = 60 * 60 * 1000;
|
|
84
|
+
/**
|
|
85
|
+
* Default cache directory
|
|
86
|
+
*/
|
|
87
|
+
const DEFAULT_CACHE_DIR = ".sequant/.cache/qa";
|
|
88
|
+
/**
|
|
89
|
+
* Files that invalidate all caches when changed
|
|
90
|
+
*/
|
|
91
|
+
const GLOBAL_INVALIDATION_FILES = [
|
|
92
|
+
"package-lock.json",
|
|
93
|
+
"package.json",
|
|
94
|
+
"tsconfig.json",
|
|
95
|
+
".sequant/settings.json",
|
|
96
|
+
];
|
|
97
|
+
/**
|
|
98
|
+
* Per-check invalidation file patterns
|
|
99
|
+
*/
|
|
100
|
+
const CHECK_INVALIDATION_PATTERNS = {
|
|
101
|
+
"type-safety": [/tsconfig.*\.json$/, /\.ts$/, /\.tsx$/],
|
|
102
|
+
"deleted-tests": [/\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/],
|
|
103
|
+
scope: [], // Uses git diff hash
|
|
104
|
+
size: [], // Uses git diff hash
|
|
105
|
+
security: [/\.ts$/, /\.tsx$/, /\.js$/, /\.jsx$/],
|
|
106
|
+
semgrep: [/\.ts$/, /\.tsx$/, /\.js$/, /\.jsx$/, /semgrep.*\.ya?ml$/],
|
|
107
|
+
build: [/\.ts$/, /\.tsx$/, /\.js$/, /\.jsx$/, /tsconfig.*\.json$/],
|
|
108
|
+
tests: [/\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /jest\.config/],
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* QA Cache Manager
|
|
112
|
+
*
|
|
113
|
+
* Manages caching of expensive QA check results to improve re-run performance.
|
|
114
|
+
*/
|
|
115
|
+
export class QACache {
|
|
116
|
+
cacheDir;
|
|
117
|
+
defaultTtl;
|
|
118
|
+
verbose;
|
|
119
|
+
cachedState = null;
|
|
120
|
+
constructor(options = {}) {
|
|
121
|
+
this.cacheDir = options.cacheDir ?? DEFAULT_CACHE_DIR;
|
|
122
|
+
this.defaultTtl = options.defaultTtl ?? DEFAULT_TTL;
|
|
123
|
+
this.verbose = options.verbose ?? false;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Get the cache file path
|
|
127
|
+
*/
|
|
128
|
+
getCachePath() {
|
|
129
|
+
return path.join(this.cacheDir, "cache.json");
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Compute hash of git diff between main and HEAD
|
|
133
|
+
*
|
|
134
|
+
* This provides a content-based cache key that changes when code changes.
|
|
135
|
+
*/
|
|
136
|
+
computeDiffHash() {
|
|
137
|
+
try {
|
|
138
|
+
// Get the diff content between main and HEAD
|
|
139
|
+
const diff = execSync("git diff main...HEAD", {
|
|
140
|
+
encoding: "utf-8",
|
|
141
|
+
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
|
|
142
|
+
});
|
|
143
|
+
// Hash the diff content
|
|
144
|
+
return crypto
|
|
145
|
+
.createHash("sha256")
|
|
146
|
+
.update(diff)
|
|
147
|
+
.digest("hex")
|
|
148
|
+
.slice(0, 16);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// If git command fails, return a unique hash to force fresh run
|
|
152
|
+
this.log("Failed to compute diff hash, using timestamp fallback");
|
|
153
|
+
return crypto
|
|
154
|
+
.createHash("sha256")
|
|
155
|
+
.update(Date.now().toString())
|
|
156
|
+
.digest("hex")
|
|
157
|
+
.slice(0, 16);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Compute hash of configuration files that affect checks
|
|
162
|
+
*/
|
|
163
|
+
computeConfigHash(checkType) {
|
|
164
|
+
const files = this.getConfigFilesForCheck(checkType);
|
|
165
|
+
const contents = [];
|
|
166
|
+
for (const file of files) {
|
|
167
|
+
if (fs.existsSync(file)) {
|
|
168
|
+
try {
|
|
169
|
+
contents.push(fs.readFileSync(file, "utf-8"));
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// Ignore read errors
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return crypto
|
|
177
|
+
.createHash("sha256")
|
|
178
|
+
.update(contents.join("\n"))
|
|
179
|
+
.digest("hex")
|
|
180
|
+
.slice(0, 16);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Get configuration files that affect a specific check type
|
|
184
|
+
*/
|
|
185
|
+
getConfigFilesForCheck(checkType) {
|
|
186
|
+
const baseFiles = ["tsconfig.json", "package.json"];
|
|
187
|
+
switch (checkType) {
|
|
188
|
+
case "type-safety":
|
|
189
|
+
case "build":
|
|
190
|
+
return [...baseFiles, "tsconfig.build.json"];
|
|
191
|
+
case "semgrep":
|
|
192
|
+
return [
|
|
193
|
+
...baseFiles,
|
|
194
|
+
".semgrep.yml",
|
|
195
|
+
".semgrepignore",
|
|
196
|
+
"semgrep.config.yaml",
|
|
197
|
+
];
|
|
198
|
+
case "tests":
|
|
199
|
+
return [
|
|
200
|
+
...baseFiles,
|
|
201
|
+
"jest.config.js",
|
|
202
|
+
"jest.config.ts",
|
|
203
|
+
"vitest.config.ts",
|
|
204
|
+
];
|
|
205
|
+
case "security":
|
|
206
|
+
return baseFiles;
|
|
207
|
+
default:
|
|
208
|
+
return baseFiles;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Check if global invalidation files have changed
|
|
213
|
+
*/
|
|
214
|
+
async checkGlobalInvalidation() {
|
|
215
|
+
try {
|
|
216
|
+
const changedFiles = execSync("git diff main...HEAD --name-only", {
|
|
217
|
+
encoding: "utf-8",
|
|
218
|
+
})
|
|
219
|
+
.trim()
|
|
220
|
+
.split("\n")
|
|
221
|
+
.filter(Boolean);
|
|
222
|
+
for (const file of changedFiles) {
|
|
223
|
+
if (GLOBAL_INVALIDATION_FILES.includes(file)) {
|
|
224
|
+
this.log(`Global invalidation triggered by: ${file}`);
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Check if files matching check-specific patterns have changed
|
|
236
|
+
*/
|
|
237
|
+
async checkTypeSpecificInvalidation(checkType) {
|
|
238
|
+
const patterns = CHECK_INVALIDATION_PATTERNS[checkType];
|
|
239
|
+
if (patterns.length === 0) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const changedFiles = execSync("git diff main...HEAD --name-only", {
|
|
244
|
+
encoding: "utf-8",
|
|
245
|
+
})
|
|
246
|
+
.trim()
|
|
247
|
+
.split("\n")
|
|
248
|
+
.filter(Boolean);
|
|
249
|
+
for (const file of changedFiles) {
|
|
250
|
+
for (const pattern of patterns) {
|
|
251
|
+
if (pattern.test(file)) {
|
|
252
|
+
this.log(`Check ${checkType} invalidated by: ${file}`);
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Read the current cache state
|
|
265
|
+
*/
|
|
266
|
+
async getState() {
|
|
267
|
+
if (this.cachedState) {
|
|
268
|
+
return this.cachedState;
|
|
269
|
+
}
|
|
270
|
+
const cachePath = this.getCachePath();
|
|
271
|
+
if (!fs.existsSync(cachePath)) {
|
|
272
|
+
const emptyState = this.createEmptyState();
|
|
273
|
+
this.cachedState = emptyState;
|
|
274
|
+
return emptyState;
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const content = fs.readFileSync(cachePath, "utf-8");
|
|
278
|
+
const parsed = JSON.parse(content);
|
|
279
|
+
const state = QACacheSchema.parse(parsed);
|
|
280
|
+
this.cachedState = state;
|
|
281
|
+
return state;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// Graceful degradation: corrupted cache -> fresh state (AC-6)
|
|
285
|
+
this.log("Cache corrupted or invalid, creating fresh cache");
|
|
286
|
+
const emptyState = this.createEmptyState();
|
|
287
|
+
this.cachedState = emptyState;
|
|
288
|
+
return emptyState;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Write cache state to disk
|
|
293
|
+
*/
|
|
294
|
+
async saveState(state) {
|
|
295
|
+
QACacheSchema.parse(state);
|
|
296
|
+
state.lastUpdated = new Date().toISOString();
|
|
297
|
+
// Ensure cache directory exists
|
|
298
|
+
if (!fs.existsSync(this.cacheDir)) {
|
|
299
|
+
fs.mkdirSync(this.cacheDir, { recursive: true });
|
|
300
|
+
}
|
|
301
|
+
const cachePath = this.getCachePath();
|
|
302
|
+
// Write atomically using temp file
|
|
303
|
+
const tempPath = `${cachePath}.tmp.${process.pid}`;
|
|
304
|
+
try {
|
|
305
|
+
fs.writeFileSync(tempPath, JSON.stringify(state, null, 2));
|
|
306
|
+
fs.renameSync(tempPath, cachePath);
|
|
307
|
+
this.cachedState = state;
|
|
308
|
+
this.log(`Cache saved: ${cachePath}`);
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
// Clean up temp file on error
|
|
312
|
+
if (fs.existsSync(tempPath)) {
|
|
313
|
+
fs.unlinkSync(tempPath);
|
|
314
|
+
}
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Get cached result for a check type
|
|
320
|
+
*/
|
|
321
|
+
async get(checkType) {
|
|
322
|
+
const state = await this.getState();
|
|
323
|
+
const cached = state.checks[checkType];
|
|
324
|
+
if (!cached) {
|
|
325
|
+
return { hit: false, isStale: false, missReason: "not-found" };
|
|
326
|
+
}
|
|
327
|
+
// Check if TTL has expired
|
|
328
|
+
const cachedTime = new Date(cached.cachedAt).getTime();
|
|
329
|
+
const now = Date.now();
|
|
330
|
+
if (now - cachedTime > cached.ttl) {
|
|
331
|
+
return { hit: false, isStale: true, missReason: "expired" };
|
|
332
|
+
}
|
|
333
|
+
// Check if diff hash matches
|
|
334
|
+
const currentDiffHash = this.computeDiffHash();
|
|
335
|
+
if (cached.diffHash !== currentDiffHash) {
|
|
336
|
+
return { hit: false, isStale: false, missReason: "hash-mismatch" };
|
|
337
|
+
}
|
|
338
|
+
// Check if config hash matches
|
|
339
|
+
const currentConfigHash = this.computeConfigHash(checkType);
|
|
340
|
+
if (cached.configHash !== currentConfigHash) {
|
|
341
|
+
return { hit: false, isStale: false, missReason: "hash-mismatch" };
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
hit: true,
|
|
345
|
+
isStale: false,
|
|
346
|
+
result: cached.result,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Cache a check result
|
|
351
|
+
*/
|
|
352
|
+
async set(checkType, result, ttl) {
|
|
353
|
+
const state = await this.getState();
|
|
354
|
+
const cachedResult = {
|
|
355
|
+
checkType,
|
|
356
|
+
diffHash: this.computeDiffHash(),
|
|
357
|
+
configHash: this.computeConfigHash(checkType),
|
|
358
|
+
cachedAt: new Date().toISOString(),
|
|
359
|
+
ttl: ttl ?? this.defaultTtl,
|
|
360
|
+
result,
|
|
361
|
+
};
|
|
362
|
+
state.checks[checkType] = cachedResult;
|
|
363
|
+
await this.saveState(state);
|
|
364
|
+
this.log(`Cached ${checkType} result`);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Clear cache for a specific check type
|
|
368
|
+
*/
|
|
369
|
+
async clear(checkType) {
|
|
370
|
+
const state = await this.getState();
|
|
371
|
+
delete state.checks[checkType];
|
|
372
|
+
await this.saveState(state);
|
|
373
|
+
this.log(`Cleared cache for ${checkType}`);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Clear all cached results
|
|
377
|
+
*/
|
|
378
|
+
async clearAll() {
|
|
379
|
+
const emptyState = this.createEmptyState();
|
|
380
|
+
await this.saveState(emptyState);
|
|
381
|
+
this.log("Cleared all cache");
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Get cache status for all check types
|
|
385
|
+
*/
|
|
386
|
+
async getStatus() {
|
|
387
|
+
const status = {};
|
|
388
|
+
for (const checkType of CHECK_TYPES) {
|
|
389
|
+
const result = await this.get(checkType);
|
|
390
|
+
status[checkType] = {
|
|
391
|
+
hit: result.hit,
|
|
392
|
+
missReason: result.missReason,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return status;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Create an empty cache state
|
|
399
|
+
*/
|
|
400
|
+
createEmptyState() {
|
|
401
|
+
return {
|
|
402
|
+
version: 1,
|
|
403
|
+
lastUpdated: new Date().toISOString(),
|
|
404
|
+
checks: {},
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Log a message if verbose mode is enabled
|
|
409
|
+
*/
|
|
410
|
+
log(message) {
|
|
411
|
+
if (this.verbose) {
|
|
412
|
+
console.log(`[qa-cache] ${message}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Clear the in-memory cache (forces re-read on next access)
|
|
417
|
+
*/
|
|
418
|
+
clearMemoryCache() {
|
|
419
|
+
this.cachedState = null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Default cache instance
|
|
424
|
+
*/
|
|
425
|
+
let defaultCache = null;
|
|
426
|
+
/**
|
|
427
|
+
* Get the default QA cache instance
|
|
428
|
+
*/
|
|
429
|
+
export function getQACache(options) {
|
|
430
|
+
if (!defaultCache || options) {
|
|
431
|
+
defaultCache = new QACache(options);
|
|
432
|
+
}
|
|
433
|
+
return defaultCache;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Reset the default cache instance (for testing)
|
|
437
|
+
*/
|
|
438
|
+
export function resetQACache() {
|
|
439
|
+
defaultCache = null;
|
|
440
|
+
}
|