@planu/cli 4.1.2 → 4.1.4
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 +21 -0
- package/dist/engine/code-scanner/layer-scanner.js +29 -116
- package/dist/engine/core-bridge.d.ts +6 -6
- package/dist/engine/core-bridge.js +35 -4
- package/dist/engine/crash-shield/index.js +26 -63
- package/dist/engine/detect-duplication.js +63 -53
- package/dist/engine/drift-monitor.js +1 -1
- package/dist/engine/hooks/file-watcher.d.ts +6 -0
- package/dist/engine/hooks/file-watcher.js +69 -16
- package/dist/engine/reviewer-tokens/signer.js +4 -22
- package/dist/engine/scan-project/module-discoverer.js +9 -11
- package/dist/engine/validator/analyzer.js +14 -11
- package/dist/engine/validator/deep-code-checker.js +4 -8
- package/dist/storage/base-store.js +2 -6
- package/dist/storage/gaps-log.js +38 -32
- package/package.json +21 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
## [4.1.4] - 2026-05-21
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
- Centralize Rust-first hot-path fallback behavior in `core-bridge` so callers use native acceleration when available and TypeScript fallbacks consistently when unavailable.
|
|
5
|
+
- Keep crash scanning, broad validation reads, gaps log verification, HMAC signing, duplicate detection, and layer scanning resilient when native reads return partial results or test doubles expose older nullable bridge behavior.
|
|
6
|
+
- Lock published native optional dependencies across macOS and Linux architectures in `pnpm` so release dependency checks remain cross-platform stable.
|
|
7
|
+
|
|
8
|
+
### Tests
|
|
9
|
+
- Re-run the full suite with 31,275 passing tests and 5 skipped tests.
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
## [4.1.3] - 2026-05-21
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
- Make the TypeScript file watcher fallback portable across operating systems by avoiding non-portable recursive `fs.watch` mode.
|
|
16
|
+
- Keep the native-core fallback path non-fatal when a platform-specific binary is unavailable.
|
|
17
|
+
|
|
18
|
+
### Tests
|
|
19
|
+
- Add coverage to prevent reintroducing recursive watcher mode in the portable fallback.
|
|
20
|
+
|
|
21
|
+
|
|
1
22
|
## [4.1.2] - 2026-05-21
|
|
2
23
|
|
|
3
24
|
### Bug Fixes
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/engine/code-scanner/layer-scanner.ts — Scans the 4 implementation layers (SPEC-441)
|
|
2
|
-
import {
|
|
3
|
-
import { join, relative,
|
|
4
|
-
import {
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { join, relative, basename } from 'node:path';
|
|
4
|
+
import { fastFindFilesByExt, fastReadFiles } from '../core-bridge.js';
|
|
5
5
|
/**
|
|
6
6
|
* Scan a project directory for evidence of spec implementation across 4 layers.
|
|
7
7
|
*/
|
|
@@ -11,29 +11,18 @@ export async function scanLayers(projectPath, keywords, symbolVariants) {
|
|
|
11
11
|
const [srcExists, testsExists] = await Promise.all([dirExists(srcDir), dirExists(testsDir)]);
|
|
12
12
|
let srcFiles = [];
|
|
13
13
|
let testFiles = [];
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
-
srcFiles = fastFindFilesByExt(srcDir, ['ts', 'js', 'tsx', 'jsx']) ?? [];
|
|
17
|
-
// Filter out .d.ts natively mapped
|
|
18
|
-
srcFiles = srcFiles.filter((f) => !f.endsWith('.d.ts'));
|
|
19
|
-
}
|
|
20
|
-
if (testsExists) {
|
|
21
|
-
testFiles = fastFindFilesByExt(testsDir, ['ts', 'js', 'tsx', 'jsx']) ?? [];
|
|
22
|
-
testFiles = testFiles.filter((f) => !f.endsWith('.d.ts'));
|
|
23
|
-
}
|
|
14
|
+
if (srcExists) {
|
|
15
|
+
srcFiles = fastFindFilesByExt(srcDir, ['ts', 'js', 'tsx', 'jsx']).filter((f) => !f.endsWith('.d.ts'));
|
|
24
16
|
}
|
|
25
|
-
|
|
26
|
-
[
|
|
27
|
-
srcExists ? collectTsFiles(srcDir) : Promise.resolve([]),
|
|
28
|
-
testsExists ? collectTsFiles(testsDir) : Promise.resolve([]),
|
|
29
|
-
]);
|
|
17
|
+
if (testsExists) {
|
|
18
|
+
testFiles = fastFindFilesByExt(testsDir, ['ts', 'js', 'tsx', 'jsx']).filter((f) => !f.endsWith('.d.ts'));
|
|
30
19
|
}
|
|
31
20
|
// Layer 1: files whose names match keywords
|
|
32
21
|
const matchedFiles = srcFiles.filter((f) => keywords.some((kw) => basename(f).toLowerCase().includes(kw)));
|
|
33
22
|
// Layer 2: exported symbols in matched files (and register/index files)
|
|
34
23
|
const registerFiles = srcFiles.filter((f) => /register.*\.ts$|index\.ts$/.test(f));
|
|
35
24
|
const filesToScanForSymbols = [...new Set([...matchedFiles, ...registerFiles])];
|
|
36
|
-
const symbols =
|
|
25
|
+
const symbols = findSymbols(filesToScanForSymbols, symbolVariants);
|
|
37
26
|
// Layer 3: test files matching keywords
|
|
38
27
|
const matchedTests = testFiles.filter((f) => keywords.some((kw) => basename(f).toLowerCase().includes(kw)));
|
|
39
28
|
// Layer 4: registrations in license-plans.json and register-*.ts files
|
|
@@ -48,78 +37,22 @@ export async function scanLayers(projectPath, keywords, symbolVariants) {
|
|
|
48
37
|
// ---------------------------------------------------------------------------
|
|
49
38
|
// Internal helpers
|
|
50
39
|
// ---------------------------------------------------------------------------
|
|
51
|
-
|
|
52
|
-
const results = [];
|
|
53
|
-
try {
|
|
54
|
-
await walkDir(dir, results);
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
// Directory not accessible — return empty
|
|
58
|
-
}
|
|
59
|
-
return results;
|
|
60
|
-
}
|
|
61
|
-
async function walkDir(dir, out) {
|
|
62
|
-
let entries;
|
|
63
|
-
try {
|
|
64
|
-
entries = await readdir(dir);
|
|
65
|
-
}
|
|
66
|
-
catch {
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
await Promise.all(entries.map(async (entry) => {
|
|
70
|
-
const full = join(dir, entry);
|
|
71
|
-
try {
|
|
72
|
-
const s = await stat(full);
|
|
73
|
-
if (s.isDirectory()) {
|
|
74
|
-
await walkDir(full, out);
|
|
75
|
-
}
|
|
76
|
-
else if (extname(entry) === '.ts' && !entry.endsWith('.d.ts')) {
|
|
77
|
-
out.push(full);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
// skip
|
|
82
|
-
}
|
|
83
|
-
}));
|
|
84
|
-
}
|
|
85
|
-
async function findSymbols(files, variants, projectPath) {
|
|
40
|
+
function findSymbols(files, variants) {
|
|
86
41
|
const found = new Set();
|
|
87
|
-
if (
|
|
88
|
-
const EXPORT_RE =
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
// Extract the group 1 (symbol name)
|
|
96
|
-
const execMatch = new RegExp(EXPORT_RE).exec(m.content);
|
|
97
|
-
const name = execMatch?.[1] ?? '';
|
|
98
|
-
if (variants.some((v) => name.toLowerCase().includes(v.toLowerCase()))) {
|
|
99
|
-
found.add(name);
|
|
100
|
-
}
|
|
42
|
+
if (files.length > 0) {
|
|
43
|
+
const EXPORT_RE = /^export\s+(?:async\s+)?(?:function|class|const|type|interface)\s+(\w+)/gm;
|
|
44
|
+
for (const file of fastReadFiles(files)) {
|
|
45
|
+
let match;
|
|
46
|
+
while ((match = EXPORT_RE.exec(file.content)) !== null) {
|
|
47
|
+
const name = match[1] ?? '';
|
|
48
|
+
if (variants.some((v) => name.toLowerCase().includes(v.toLowerCase()))) {
|
|
49
|
+
found.add(name);
|
|
101
50
|
}
|
|
102
51
|
}
|
|
103
|
-
|
|
52
|
+
EXPORT_RE.lastIndex = 0;
|
|
104
53
|
}
|
|
54
|
+
return [...found];
|
|
105
55
|
}
|
|
106
|
-
const EXPORT_RE_NODE = /^export\s+(?:async\s+)?(?:function|class|const|type|interface)\s+(\w+)/gm;
|
|
107
|
-
await Promise.all(files.map(async (f) => {
|
|
108
|
-
let content;
|
|
109
|
-
try {
|
|
110
|
-
content = await readFile(f, 'utf8');
|
|
111
|
-
}
|
|
112
|
-
catch {
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
let match;
|
|
116
|
-
while ((match = EXPORT_RE_NODE.exec(content)) !== null) {
|
|
117
|
-
const name = match[1] ?? '';
|
|
118
|
-
if (variants.some((v) => name.toLowerCase().includes(v.toLowerCase()))) {
|
|
119
|
-
found.add(name);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}));
|
|
123
56
|
return [...found];
|
|
124
57
|
}
|
|
125
58
|
async function findRegistrations(projectPath, srcFiles, keywords) {
|
|
@@ -141,40 +74,20 @@ async function findRegistrations(projectPath, srcFiles, keywords) {
|
|
|
141
74
|
}
|
|
142
75
|
// Check register-*.ts files for registerTool calls matching keywords
|
|
143
76
|
const registerFiles = srcFiles.filter((f) => /register.*\.ts$/.test(f));
|
|
144
|
-
if (
|
|
145
|
-
const REGISTER_RE =
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
const toolName = execMatch?.[1] ?? '';
|
|
153
|
-
if (keywords.some((kw) => toolName.includes(kw))) {
|
|
154
|
-
found.add(`registered:${toolName}`);
|
|
155
|
-
}
|
|
77
|
+
if (registerFiles.length > 0) {
|
|
78
|
+
const REGISTER_RE = /registerTool\s*\(\s*['"]([^'"]+)['"]/g;
|
|
79
|
+
for (const file of fastReadFiles(registerFiles)) {
|
|
80
|
+
let match;
|
|
81
|
+
while ((match = REGISTER_RE.exec(file.content)) !== null) {
|
|
82
|
+
const toolName = match[1] ?? '';
|
|
83
|
+
if (keywords.some((kw) => toolName.includes(kw))) {
|
|
84
|
+
found.add(`registered:${toolName}`);
|
|
156
85
|
}
|
|
157
86
|
}
|
|
158
|
-
|
|
87
|
+
REGISTER_RE.lastIndex = 0;
|
|
159
88
|
}
|
|
89
|
+
return [...found];
|
|
160
90
|
}
|
|
161
|
-
const REGISTER_RE_NODE = /registerTool\s*\(\s*['"]([^'"]+)['"]/g;
|
|
162
|
-
await Promise.all(registerFiles.map(async (f) => {
|
|
163
|
-
let content;
|
|
164
|
-
try {
|
|
165
|
-
content = await readFile(f, 'utf8');
|
|
166
|
-
}
|
|
167
|
-
catch {
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
let match;
|
|
171
|
-
while ((match = REGISTER_RE_NODE.exec(content)) !== null) {
|
|
172
|
-
const toolName = match[1] ?? '';
|
|
173
|
-
if (keywords.some((kw) => toolName.includes(kw))) {
|
|
174
|
-
found.add(`registered:${toolName}`);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}));
|
|
178
91
|
return [...found];
|
|
179
92
|
}
|
|
180
93
|
async function dirExists(p) {
|
|
@@ -12,12 +12,12 @@ export declare function fastScanAndHashFiles(rootPath: string): FileHash[];
|
|
|
12
12
|
export declare function fastScanProjectMetadata(rootPath: string): FileMetadata[];
|
|
13
13
|
export declare function fastScanSpecAnnotations(rootPath: string): SpecAnnotation[];
|
|
14
14
|
export declare function fastCheckAcCoverage(filePaths: string[], keywords: string[]): string[];
|
|
15
|
-
export declare function fastDetectDriftParallel(rootPath: string, keywords: string[]): DriftResult
|
|
16
|
-
export declare function fastFindDuplicateBlocks(paths: string[], minLines: number): DuplicateRawBlockRs[]
|
|
17
|
-
export declare function fastFindPatterns(rootPath: string, patterns: string[], extensions?: string[], exclude?: string[]): FastGrepMatch[]
|
|
18
|
-
export declare function fastFindFilesByName(rootPath: string, names: string[]): string[]
|
|
19
|
-
export declare function fastFindFilesByExt(rootPath: string, extensions: string[]): string[]
|
|
20
|
-
export declare function fastReadFiles(paths: string[]): FileContent[]
|
|
15
|
+
export declare function fastDetectDriftParallel(rootPath: string, keywords: string[]): DriftResult;
|
|
16
|
+
export declare function fastFindDuplicateBlocks(paths: string[], minLines: number): DuplicateRawBlockRs[];
|
|
17
|
+
export declare function fastFindPatterns(rootPath: string, patterns: string[], extensions?: string[], exclude?: string[]): FastGrepMatch[];
|
|
18
|
+
export declare function fastFindFilesByName(rootPath: string, names: string[]): string[];
|
|
19
|
+
export declare function fastFindFilesByExt(rootPath: string, extensions: string[]): string[];
|
|
20
|
+
export declare function fastReadFiles(paths: string[]): FileContent[];
|
|
21
21
|
export declare function fastScanSpecs(rootPath: string): SpecBriefRs[] | null;
|
|
22
22
|
export declare function fastVerifyGapsChain(filePath: string): GapsLogChainResultRs;
|
|
23
23
|
export declare function fastAppendGapEntry(filePath: string, id: string, timestamp: string, specId: string, description: string, severity: string, affectedSpecs: string[]): string;
|
|
@@ -180,6 +180,7 @@ const getNative = () => process.env.DISABLE_NATIVE_CORE === '1' ? null : nativeM
|
|
|
180
180
|
const PROJECT_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
181
181
|
const SPEC_ANNOTATION_RE = /@spec\s+(SPEC-\d+)(?:\s+AC:([\d,]+))?(?:\s+(.*))?/;
|
|
182
182
|
const SCANNABLE_EXTS = new Set(['.ts', '.js', '.tsx', '.jsx', '.rs', '.py', '.go']);
|
|
183
|
+
const MAX_TS_WATCHERS = 128;
|
|
183
184
|
const IGNORED_DIRS = new Set([
|
|
184
185
|
'node_modules',
|
|
185
186
|
'.git',
|
|
@@ -671,11 +672,41 @@ export function startNativeWatcher(rootPath, callback) {
|
|
|
671
672
|
n.startProjectWatcher(rootPath, callback);
|
|
672
673
|
return;
|
|
673
674
|
}
|
|
674
|
-
// TS fallback:
|
|
675
|
+
// TS fallback: watch each existing directory. Node's `recursive: true` is not
|
|
676
|
+
// portable across all OS/libc combinations, and unsupported platforms must not
|
|
677
|
+
// crash the MCP server when the native watcher is unavailable.
|
|
675
678
|
const fs = nodeFs;
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
+
let watcherCount = 0;
|
|
680
|
+
const watchDir = (absDir, relDir) => {
|
|
681
|
+
if (watcherCount >= MAX_TS_WATCHERS) {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const watcher = fs.watch(absDir, {}, (event, filename) => {
|
|
685
|
+
callback(null, `${event}:${join(relDir, filename?.toString() ?? '')}`);
|
|
686
|
+
});
|
|
687
|
+
watcherCount++;
|
|
688
|
+
watcher.on('error', (err) => {
|
|
689
|
+
callback(err, '');
|
|
690
|
+
});
|
|
691
|
+
};
|
|
692
|
+
const walkDirs = (absDir, relDir) => {
|
|
693
|
+
watchDir(absDir, relDir);
|
|
694
|
+
let entries;
|
|
695
|
+
try {
|
|
696
|
+
entries = readdirSync(absDir, { withFileTypes: true });
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
for (const entry of entries) {
|
|
702
|
+
if (!entry.isDirectory() || IGNORED_DIRS.has(entry.name)) {
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
const childRel = join(relDir, entry.name);
|
|
706
|
+
walkDirs(join(absDir, entry.name), childRel);
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
walkDirs(rootPath, '');
|
|
679
710
|
}
|
|
680
711
|
export const isNativeActive = () => nativeModule !== null && process.env.DISABLE_NATIVE_CORE !== '1';
|
|
681
712
|
export const nativeLoadDiagnostic = () => loadDiagnostic;
|
|
@@ -7,7 +7,7 @@ import { goDetector } from './detectors/go-detector.js';
|
|
|
7
7
|
import { rustDetector } from './detectors/rust-detector.js';
|
|
8
8
|
import { javaDetector } from './detectors/java-detector.js';
|
|
9
9
|
import { frameworkDetector } from './detectors/framework-detector.js';
|
|
10
|
-
import {
|
|
10
|
+
import { fastReadFiles } from '../core-bridge.js';
|
|
11
11
|
export { fixCrashRisks } from './auto-fixer/index.js';
|
|
12
12
|
const ALL_DETECTORS = [
|
|
13
13
|
typescriptDetector,
|
|
@@ -17,7 +17,6 @@ const ALL_DETECTORS = [
|
|
|
17
17
|
javaDetector,
|
|
18
18
|
frameworkDetector,
|
|
19
19
|
];
|
|
20
|
-
const FILE_SCAN_TIMEOUT_MS = 1000;
|
|
21
20
|
/**
|
|
22
21
|
* Scans a project for crash risks and returns a SafetyReport.
|
|
23
22
|
* @param projectPath - absolute path to the project root
|
|
@@ -39,80 +38,44 @@ export async function scanCrashRisks(projectPath, stackHint) {
|
|
|
39
38
|
}
|
|
40
39
|
async function scanFiles(files, detectedStack) {
|
|
41
40
|
const applicableDetectors = selectDetectors(detectedStack);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const found = detector.detect(path, content);
|
|
51
|
-
risks.push(...found);
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
// Detector error: skip silently
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return risks;
|
|
58
|
-
});
|
|
59
|
-
return results.flat();
|
|
41
|
+
const fileContents = fastReadFiles(files);
|
|
42
|
+
const seen = new Set(fileContents.map((file) => file.path));
|
|
43
|
+
const results = fileContents.map(({ path, content }) => detectRisksInContent(path, content, applicableDetectors));
|
|
44
|
+
const unreadByBridge = files.filter((file) => !seen.has(file));
|
|
45
|
+
const fallbackResults = await Promise.all(unreadByBridge.map(async (file) => {
|
|
46
|
+
try {
|
|
47
|
+
const content = await readFile(file, 'utf-8');
|
|
48
|
+
return detectRisksInContent(file, content, applicableDetectors);
|
|
60
49
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return results.flat();
|
|
64
|
-
}
|
|
65
|
-
function selectDetectors(detectedStack) {
|
|
66
|
-
if (detectedStack.length === 0) {
|
|
67
|
-
return ALL_DETECTORS;
|
|
68
|
-
}
|
|
69
|
-
return ALL_DETECTORS.filter((detector) => {
|
|
70
|
-
if (detector.language === 'multi') {
|
|
71
|
-
return true;
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
72
52
|
}
|
|
73
|
-
|
|
74
|
-
|
|
53
|
+
}));
|
|
54
|
+
return [...results, ...fallbackResults].flat();
|
|
75
55
|
}
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
if (applicableDetectors.length === 0) {
|
|
79
|
-
return [];
|
|
80
|
-
}
|
|
81
|
-
let content;
|
|
82
|
-
try {
|
|
83
|
-
content = await withTimeout(readFile(file, 'utf-8'), FILE_SCAN_TIMEOUT_MS, file);
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
return [];
|
|
87
|
-
}
|
|
56
|
+
function detectRisksInContent(path, content, applicableDetectors) {
|
|
57
|
+
const fileDetectors = applicableDetectors.filter((d) => d.fileExtensions.some((ext) => path.endsWith(ext)));
|
|
88
58
|
const risks = [];
|
|
89
|
-
for (const detector of
|
|
59
|
+
for (const detector of fileDetectors) {
|
|
90
60
|
try {
|
|
91
|
-
const found = detector.detect(
|
|
61
|
+
const found = detector.detect(path, content);
|
|
92
62
|
risks.push(...found);
|
|
93
63
|
}
|
|
94
64
|
catch {
|
|
95
|
-
/* c8 ignore next */
|
|
96
65
|
// Detector error: skip silently
|
|
97
66
|
}
|
|
98
67
|
}
|
|
99
68
|
return risks;
|
|
100
69
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
},
|
|
111
|
-
/* c8 ignore next 4 */
|
|
112
|
-
(err) => {
|
|
113
|
-
clearTimeout(timer);
|
|
114
|
-
reject(err instanceof Error ? err : new Error(String(err)));
|
|
115
|
-
});
|
|
70
|
+
function selectDetectors(detectedStack) {
|
|
71
|
+
if (detectedStack.length === 0) {
|
|
72
|
+
return ALL_DETECTORS;
|
|
73
|
+
}
|
|
74
|
+
return ALL_DETECTORS.filter((detector) => {
|
|
75
|
+
if (detector.language === 'multi') {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return detectedStack.includes(detector.language);
|
|
116
79
|
});
|
|
117
80
|
}
|
|
118
81
|
//# sourceMappingURL=index.js.map
|
|
@@ -10,24 +10,6 @@ const LANGUAGE_EXTENSIONS = {
|
|
|
10
10
|
typescript: ['.ts', '.tsx'],
|
|
11
11
|
javascript: ['.js', '.jsx', '.mjs', '.cjs'],
|
|
12
12
|
};
|
|
13
|
-
/**
|
|
14
|
-
* Normalize a single line: remove inline comments, trim, lowercase.
|
|
15
|
-
*/
|
|
16
|
-
function normalizeLine(line) {
|
|
17
|
-
let trimmed = line;
|
|
18
|
-
// Remove single-line comments (but not URLs like http://)
|
|
19
|
-
const commentIdx = trimmed.indexOf('//');
|
|
20
|
-
if (commentIdx >= 0) {
|
|
21
|
-
trimmed = trimmed.slice(0, commentIdx);
|
|
22
|
-
}
|
|
23
|
-
return trimmed.trim().toLowerCase();
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Remove multi-line comment blocks from source text.
|
|
27
|
-
*/
|
|
28
|
-
function stripMultilineComments(text) {
|
|
29
|
-
return text.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
30
|
-
}
|
|
31
13
|
/**
|
|
32
14
|
* Build glob patterns for the requested languages.
|
|
33
15
|
*/
|
|
@@ -46,6 +28,24 @@ function buildIgnorePatterns(userExclude) {
|
|
|
46
28
|
const user = userExclude ?? [];
|
|
47
29
|
return [...DEFAULT_EXCLUDE, ...user];
|
|
48
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Normalize a single line: remove inline comments, trim, lowercase.
|
|
33
|
+
*/
|
|
34
|
+
function normalizeLine(line) {
|
|
35
|
+
let trimmed = line;
|
|
36
|
+
// Remove single-line comments (but not URLs like http://)
|
|
37
|
+
const commentIdx = trimmed.indexOf('//');
|
|
38
|
+
if (commentIdx >= 0) {
|
|
39
|
+
trimmed = trimmed.slice(0, commentIdx);
|
|
40
|
+
}
|
|
41
|
+
return trimmed.trim().toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Remove multi-line comment blocks from source text.
|
|
45
|
+
*/
|
|
46
|
+
function stripMultilineComments(text) {
|
|
47
|
+
return text.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
48
|
+
}
|
|
49
49
|
/**
|
|
50
50
|
* Hash an array of normalized lines using SHA-256 (first 16 chars for speed).
|
|
51
51
|
*/
|
|
@@ -153,7 +153,47 @@ function buildSuggestion(cluster) {
|
|
|
153
153
|
}
|
|
154
154
|
return `Extract ${String(lineCount)} lines from ${first.path}:${String(first.startLine)} and ${second.path}:${String(second.startLine)} into shared utility`;
|
|
155
155
|
}
|
|
156
|
-
import {
|
|
156
|
+
import { fastFindDuplicateBlocks } from './core-bridge.js';
|
|
157
|
+
async function detectDuplicateBlocksFallback(uniqueFiles, minLines) {
|
|
158
|
+
const hashMap = new Map();
|
|
159
|
+
for (const filePath of uniqueFiles) {
|
|
160
|
+
let content;
|
|
161
|
+
try {
|
|
162
|
+
content = await readFile(filePath, 'utf-8');
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
content = stripMultilineComments(content);
|
|
168
|
+
const rawLines = content.split('\n');
|
|
169
|
+
const blocks = extractBlocks(filePath, rawLines, minLines);
|
|
170
|
+
for (const block of blocks) {
|
|
171
|
+
const h = hashLines(block.lines);
|
|
172
|
+
const existing = hashMap.get(h) ?? [];
|
|
173
|
+
existing.push(block);
|
|
174
|
+
hashMap.set(h, existing);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const rawClusters = [];
|
|
178
|
+
for (const [h, locations] of hashMap.entries()) {
|
|
179
|
+
if (locations.length >= 2) {
|
|
180
|
+
rawClusters.push({ hash: h, locations });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return rawClusters;
|
|
184
|
+
}
|
|
185
|
+
function isDuplicateRawBlockArray(value) {
|
|
186
|
+
if (!Array.isArray(value)) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return value.every((item) => {
|
|
190
|
+
if (typeof item !== 'object' || item === null) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
const candidate = item;
|
|
194
|
+
return typeof candidate.hash === 'string' && Array.isArray(candidate.locations);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
157
197
|
/**
|
|
158
198
|
* Detect code duplication across project files.
|
|
159
199
|
*/
|
|
@@ -173,40 +213,10 @@ export async function detectDuplication(input) {
|
|
|
173
213
|
}
|
|
174
214
|
// Deduplicate file list
|
|
175
215
|
const uniqueFiles = [...new Set(allFiles)];
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
rawClusters = nativeBlocks;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
else {
|
|
184
|
-
const hashMap = new Map();
|
|
185
|
-
for (const filePath of uniqueFiles) {
|
|
186
|
-
let content;
|
|
187
|
-
try {
|
|
188
|
-
content = await readFile(filePath, 'utf-8');
|
|
189
|
-
}
|
|
190
|
-
catch {
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
content = stripMultilineComments(content);
|
|
194
|
-
const rawLines = content.split('\n');
|
|
195
|
-
const blocks = extractBlocks(filePath, rawLines, minLines);
|
|
196
|
-
for (const block of blocks) {
|
|
197
|
-
const h = hashLines(block.lines);
|
|
198
|
-
const existing = hashMap.get(h) ?? [];
|
|
199
|
-
existing.push(block);
|
|
200
|
-
hashMap.set(h, existing);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
// Find collisions (same hash, different files or different locations)
|
|
204
|
-
for (const [h, locations] of hashMap.entries()) {
|
|
205
|
-
if (locations.length >= 2) {
|
|
206
|
-
rawClusters.push({ hash: h, locations });
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
}
|
|
216
|
+
const bridgeResult = fastFindDuplicateBlocks(uniqueFiles, minLines);
|
|
217
|
+
const rawClusters = isDuplicateRawBlockArray(bridgeResult)
|
|
218
|
+
? bridgeResult
|
|
219
|
+
: await detectDuplicateBlocksFallback(uniqueFiles, minLines);
|
|
210
220
|
// Group locations into clusters, merging per-file overlaps
|
|
211
221
|
const clusters = [];
|
|
212
222
|
let clusterId = 0;
|
|
@@ -281,7 +281,7 @@ async function computeAcCoverageScore(criteria, filePaths) {
|
|
|
281
281
|
}
|
|
282
282
|
const rootPath = path.dirname(firstFile); // Use first file's dir as heuristic
|
|
283
283
|
const result = fastDetectDriftParallel(rootPath, [...allTerms]);
|
|
284
|
-
foundTerms = new Set(result
|
|
284
|
+
foundTerms = new Set(result.foundKeywords);
|
|
285
285
|
}
|
|
286
286
|
else {
|
|
287
287
|
// Fallback: Read all implementation files once
|
|
@@ -18,9 +18,11 @@ export declare function matchesGlobPattern(filePath: string, pattern: string): b
|
|
|
18
18
|
export declare function matchesPatterns(filePath: string, include?: string[], exclude?: string[]): boolean;
|
|
19
19
|
export declare class FileWatcher extends EventEmitter {
|
|
20
20
|
private watcher;
|
|
21
|
+
private readonly watchers;
|
|
21
22
|
private readonly rootDir;
|
|
22
23
|
private readonly include;
|
|
23
24
|
private readonly exclude;
|
|
25
|
+
private readonly maxWatchers;
|
|
24
26
|
/** Track known files to distinguish create vs modify */
|
|
25
27
|
private readonly knownFiles;
|
|
26
28
|
private closed;
|
|
@@ -42,6 +44,10 @@ export declare class FileWatcher extends EventEmitter {
|
|
|
42
44
|
* Graceful shutdown: release all handles.
|
|
43
45
|
*/
|
|
44
46
|
close(): void;
|
|
47
|
+
private startTypeScriptWatcher;
|
|
48
|
+
private watchDirectory;
|
|
49
|
+
private watchSubdirectories;
|
|
50
|
+
private emitWatcherError;
|
|
45
51
|
private handleFsEvent;
|
|
46
52
|
private determineEventType;
|
|
47
53
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// engine/hooks/file-watcher.ts — File system watcher with normalized events (SPEC-129)
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
|
-
import { watch } from 'node:fs';
|
|
3
|
+
import { readdirSync, watch } from 'node:fs';
|
|
4
4
|
import { stat } from 'node:fs/promises';
|
|
5
5
|
import { normalize, join } from 'node:path';
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
@@ -73,15 +73,22 @@ export function matchesPatterns(filePath, include, exclude) {
|
|
|
73
73
|
// Default excludes
|
|
74
74
|
// ---------------------------------------------------------------------------
|
|
75
75
|
const DEFAULT_EXCLUDES = ['data/**', 'node_modules/**', '.git/**'];
|
|
76
|
+
const DEFAULT_MAX_TS_WATCHERS = 128;
|
|
77
|
+
function maxTypeScriptWatchers() {
|
|
78
|
+
const raw = Number(process.env.PLANU_MAX_TS_WATCHERS ?? DEFAULT_MAX_TS_WATCHERS);
|
|
79
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_TS_WATCHERS;
|
|
80
|
+
}
|
|
76
81
|
// ---------------------------------------------------------------------------
|
|
77
82
|
// FileWatcher
|
|
78
83
|
// ---------------------------------------------------------------------------
|
|
79
84
|
import { isNativeActive, startNativeWatcher } from '../core-bridge.js';
|
|
80
85
|
export class FileWatcher extends EventEmitter {
|
|
81
86
|
watcher = null;
|
|
87
|
+
watchers = new Set();
|
|
82
88
|
rootDir;
|
|
83
89
|
include;
|
|
84
90
|
exclude;
|
|
91
|
+
maxWatchers = maxTypeScriptWatchers();
|
|
85
92
|
/** Track known files to distinguish create vs modify */
|
|
86
93
|
knownFiles = new Set();
|
|
87
94
|
closed = false;
|
|
@@ -112,21 +119,11 @@ export class FileWatcher extends EventEmitter {
|
|
|
112
119
|
});
|
|
113
120
|
}
|
|
114
121
|
else {
|
|
115
|
-
this.
|
|
116
|
-
if (!filename || this.closed) {
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
// Normalize path separator
|
|
120
|
-
const relPath = filename.replace(/\\/g, '/');
|
|
121
|
-
void this.handleFsEvent(relPath);
|
|
122
|
-
});
|
|
123
|
-
this.watcher.on('error', (err) => {
|
|
124
|
-
this.emit('error', err);
|
|
125
|
-
});
|
|
122
|
+
this.startTypeScriptWatcher();
|
|
126
123
|
}
|
|
127
124
|
}
|
|
128
125
|
catch (err) {
|
|
129
|
-
this.
|
|
126
|
+
this.emitWatcherError(err);
|
|
130
127
|
}
|
|
131
128
|
}
|
|
132
129
|
/**
|
|
@@ -148,15 +145,71 @@ export class FileWatcher extends EventEmitter {
|
|
|
148
145
|
*/
|
|
149
146
|
close() {
|
|
150
147
|
this.closed = true;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
this.watcher = null;
|
|
148
|
+
for (const watcher of this.watchers) {
|
|
149
|
+
watcher.close();
|
|
154
150
|
}
|
|
151
|
+
this.watchers.clear();
|
|
152
|
+
this.watcher = null;
|
|
155
153
|
this.removeAllListeners();
|
|
156
154
|
}
|
|
157
155
|
// ---------------------------------------------------------------------------
|
|
158
156
|
// Private
|
|
159
157
|
// ---------------------------------------------------------------------------
|
|
158
|
+
startTypeScriptWatcher() {
|
|
159
|
+
this.watchDirectory(this.rootDir, '');
|
|
160
|
+
this.watchSubdirectories(this.rootDir, '');
|
|
161
|
+
}
|
|
162
|
+
watchDirectory(absDir, relDir) {
|
|
163
|
+
const watcher = watch(absDir, {}, (_eventType, filename) => {
|
|
164
|
+
if (!filename || this.closed) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const relPath = join(relDir, filename).replace(/\\/g, '/');
|
|
168
|
+
void this.handleFsEvent(relPath);
|
|
169
|
+
});
|
|
170
|
+
watcher.on('error', (err) => {
|
|
171
|
+
this.emitWatcherError(err);
|
|
172
|
+
});
|
|
173
|
+
this.watchers.add(watcher);
|
|
174
|
+
this.watcher ??= watcher;
|
|
175
|
+
}
|
|
176
|
+
watchSubdirectories(absDir, relDir) {
|
|
177
|
+
if (this.watchers.size >= this.maxWatchers) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
let entries;
|
|
181
|
+
try {
|
|
182
|
+
entries = readdirSync(absDir, { withFileTypes: true });
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
for (const entry of entries) {
|
|
188
|
+
if (!entry.isDirectory()) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const childRel = join(relDir, entry.name).replace(/\\/g, '/');
|
|
192
|
+
if (this.exclude.some((p) => matchesGlobPattern(childRel, p))) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const childAbs = join(absDir, entry.name);
|
|
196
|
+
try {
|
|
197
|
+
this.watchDirectory(childAbs, childRel);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (this.watchers.size >= this.maxWatchers) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
this.watchSubdirectories(childAbs, childRel);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
emitWatcherError(err) {
|
|
209
|
+
if (this.listenerCount('error') > 0) {
|
|
210
|
+
this.emit('error', err);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
160
213
|
async handleFsEvent(relPath) {
|
|
161
214
|
try {
|
|
162
215
|
// Check exclude patterns first
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// engine/reviewer-tokens/signer.ts — HMAC-SHA256 sign/verify for reviewer tokens (SPEC-722)
|
|
2
|
-
import {
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { readFile, writeFile, mkdir, appendFile } from 'node:fs/promises';
|
|
4
4
|
import { join, dirname } from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { fastHmacSign, fastHmacVerify } from '../core-bridge.js';
|
|
6
6
|
const SECRET_FILENAME = '.planu-secret';
|
|
7
7
|
const GITIGNORE_ENTRY = '.planu-secret\n';
|
|
8
8
|
let cachedSecret = null;
|
|
@@ -55,29 +55,11 @@ export function canonicalJson(obj) {
|
|
|
55
55
|
}
|
|
56
56
|
/** Compute HMAC-SHA256 over `data` using `secret`. Returns hex digest. */
|
|
57
57
|
export function hmacSign(secret, data) {
|
|
58
|
-
|
|
59
|
-
const sig = fastHmacSign(secret, data);
|
|
60
|
-
if (sig) {
|
|
61
|
-
return sig;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return createHmac('sha256', secret).update(data).digest('hex');
|
|
58
|
+
return fastHmacSign(secret, data);
|
|
65
59
|
}
|
|
66
60
|
/** Verify a previously computed HMAC-SHA256 signature. Constant-time compare via crypto. */
|
|
67
61
|
export function hmacVerify(secret, data, signature) {
|
|
68
|
-
|
|
69
|
-
return fastHmacVerify(secret, data, signature);
|
|
70
|
-
}
|
|
71
|
-
const expected = hmacSign(secret, data);
|
|
72
|
-
// Constant-time comparison to prevent timing attacks.
|
|
73
|
-
if (expected.length !== signature.length) {
|
|
74
|
-
return false;
|
|
75
|
-
}
|
|
76
|
-
let mismatch = 0;
|
|
77
|
-
for (let i = 0; i < expected.length; i++) {
|
|
78
|
-
mismatch |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
|
|
79
|
-
}
|
|
80
|
-
return mismatch === 0;
|
|
62
|
+
return fastHmacVerify(secret, data, signature);
|
|
81
63
|
}
|
|
82
64
|
/** Reset cached secret (test helper only — not exported from index). */
|
|
83
65
|
export function _resetSecretCache() {
|
|
@@ -212,16 +212,14 @@ async function detectMonorepo(projectPath) {
|
|
|
212
212
|
if (isNativeActive()) {
|
|
213
213
|
// Rust: optimized directory resolution for monorepo patterns
|
|
214
214
|
const pkgJsonPaths = fastFindFilesByName(projectPath, ['package.json']);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
resolved.push(...dirs);
|
|
224
|
-
}
|
|
215
|
+
// Filter directories that match the workspace globs (simplified heuristic).
|
|
216
|
+
const dirs = pkgJsonPaths
|
|
217
|
+
.map((p) => dirname(p))
|
|
218
|
+
.map((p) => relative(projectPath, p))
|
|
219
|
+
.filter((rel) => rel !== '' && !isIgnored(rel.split('/')[0] ?? ''));
|
|
220
|
+
// In a real implementation we would match against workspaceGlobs exactly,
|
|
221
|
+
// but for speed we take all dirs with package.json that aren't ignored.
|
|
222
|
+
resolved.push(...dirs);
|
|
225
223
|
}
|
|
226
224
|
if (resolved.length === 0) {
|
|
227
225
|
for (const pattern of workspaceGlobs) {
|
|
@@ -258,7 +256,7 @@ async function detectMonorepo(projectPath) {
|
|
|
258
256
|
async function detectPackageBased(projectPath) {
|
|
259
257
|
if (isNativeActive()) {
|
|
260
258
|
const manifests = fastFindFilesByName(projectPath, PACKAGE_MANIFESTS);
|
|
261
|
-
if (manifests
|
|
259
|
+
if (manifests.length > 0) {
|
|
262
260
|
const modules = [];
|
|
263
261
|
const seen = new Set();
|
|
264
262
|
for (const m of manifests) {
|
|
@@ -7,7 +7,7 @@ import { glob } from 'glob';
|
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
const BROAD_SCAN_IGNORE = ['node_modules/**', 'dist/**', 'build/**', '.git/**'];
|
|
9
9
|
const BROAD_SCAN_MAX = 300;
|
|
10
|
-
import {
|
|
10
|
+
import { fastReadFiles } from '../core-bridge.js';
|
|
11
11
|
/**
|
|
12
12
|
* Build a file-contents cache for a broader search across the project.
|
|
13
13
|
* Caps at BROAD_SCAN_MAX files to bound I/O on large repos.
|
|
@@ -21,18 +21,21 @@ async function buildBroadCache(projectPath) {
|
|
|
21
21
|
ignore: BROAD_SCAN_IGNORE,
|
|
22
22
|
maxDepth: 5,
|
|
23
23
|
});
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
24
|
+
const cappedFiles = files.slice(0, BROAD_SCAN_MAX);
|
|
25
|
+
const absoluteFiles = cappedFiles.map((f) => join(projectPath, f));
|
|
26
|
+
const results = fastReadFiles(absoluteFiles);
|
|
27
|
+
const loaded = new Set();
|
|
28
|
+
for (const res of results) {
|
|
29
|
+
cache.set(relative(projectPath, res.path), res.content);
|
|
30
|
+
loaded.add(res.path);
|
|
32
31
|
}
|
|
33
|
-
await Promise.all(
|
|
32
|
+
await Promise.all(cappedFiles.map(async (file, index) => {
|
|
33
|
+
const absPath = absoluteFiles[index];
|
|
34
|
+
if (!absPath || loaded.has(absPath)) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
34
37
|
try {
|
|
35
|
-
const content = await readFile(
|
|
38
|
+
const content = await readFile(absPath, 'utf-8');
|
|
36
39
|
cache.set(file, content);
|
|
37
40
|
}
|
|
38
41
|
catch {
|
|
@@ -115,12 +115,13 @@ async function resolveFiles(scopeGlob, projectPath, exclude) {
|
|
|
115
115
|
return [];
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
|
+
// ─── Checkers ─────────────────────────────────────────────────────────────────
|
|
119
|
+
import { isNativeActive, fastFindPatterns } from '../core-bridge.js';
|
|
118
120
|
function buildRegex(pattern) {
|
|
119
121
|
try {
|
|
120
122
|
return new RegExp(pattern, 'g');
|
|
121
123
|
}
|
|
122
124
|
catch {
|
|
123
|
-
// If pattern is not a valid regex, escape it
|
|
124
125
|
try {
|
|
125
126
|
const escaped = RegExp.escape(pattern);
|
|
126
127
|
return new RegExp(escaped, 'g');
|
|
@@ -130,8 +131,6 @@ function buildRegex(pattern) {
|
|
|
130
131
|
}
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
|
-
// ─── Checkers ─────────────────────────────────────────────────────────────────
|
|
134
|
-
import { isNativeActive, fastFindPatterns } from '../core-bridge.js';
|
|
135
134
|
/**
|
|
136
135
|
* Search for a regex pattern in files matching the scope glob.
|
|
137
136
|
* For grep_absent: passed when matchCount === 0.
|
|
@@ -140,11 +139,8 @@ import { isNativeActive, fastFindPatterns } from '../core-bridge.js';
|
|
|
140
139
|
export async function grepCheck(config, projectPath, checkType) {
|
|
141
140
|
const evidence = [];
|
|
142
141
|
if (isNativeActive()) {
|
|
143
|
-
// Rust: optimized parallel search
|
|
144
142
|
const results = fastFindPatterns(projectPath, [config.pattern], [], config.exclude ? [config.exclude] : []);
|
|
145
|
-
|
|
146
|
-
evidence.push(...results.map((r) => `${relative(projectPath, r.path)}:${r.line}`));
|
|
147
|
-
}
|
|
143
|
+
evidence.push(...results.map((r) => `${relative(projectPath, r.path)}:${r.line}`));
|
|
148
144
|
}
|
|
149
145
|
else {
|
|
150
146
|
const files = await resolveFiles(config.scope, projectPath, config.exclude);
|
|
@@ -159,7 +155,7 @@ export async function grepCheck(config, projectPath, checkType) {
|
|
|
159
155
|
const line = lines[i];
|
|
160
156
|
if (line !== undefined && regex.test(line)) {
|
|
161
157
|
evidence.push(`${file}:${i + 1}`);
|
|
162
|
-
regex.lastIndex = 0;
|
|
158
|
+
regex.lastIndex = 0;
|
|
163
159
|
}
|
|
164
160
|
}
|
|
165
161
|
}
|
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
import { readFile, mkdir } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
-
import { createHash } from 'node:crypto';
|
|
7
6
|
import { writeJsonSafe } from '../engine/safety/atomic-writer.js';
|
|
8
|
-
import { fastHashProjectPath
|
|
7
|
+
import { fastHashProjectPath } from '../engine/core-bridge.js';
|
|
9
8
|
/**
|
|
10
9
|
* Runtime-safe JSON.parse with typed fallback.
|
|
11
10
|
*
|
|
@@ -37,10 +36,7 @@ export function safeJsonParse(raw, fallback) {
|
|
|
37
36
|
* Hash a project path into a deterministic projectId using SHA-256.
|
|
38
37
|
*/
|
|
39
38
|
export function hashProjectPath(projectPath) {
|
|
40
|
-
|
|
41
|
-
return fastHashProjectPath(projectPath);
|
|
42
|
-
}
|
|
43
|
-
return createHash('sha256').update(projectPath).digest('hex').slice(0, 16);
|
|
39
|
+
return fastHashProjectPath(projectPath).slice(0, 16);
|
|
44
40
|
}
|
|
45
41
|
/**
|
|
46
42
|
* Ensure the parent directory for a file path exists.
|
package/dist/storage/gaps-log.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { createHash, randomUUID } from 'node:crypto';
|
|
8
8
|
import { appendFile, readFile, mkdir } from 'node:fs/promises';
|
|
9
9
|
import { dirname, join } from 'node:path';
|
|
10
|
-
import {
|
|
10
|
+
import { fastAppendGapEntry, fastVerifyGapsChain } from '../engine/core-bridge.js';
|
|
11
11
|
import { projectDataDir } from './base-store.js';
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
// Path helper
|
|
@@ -53,26 +53,22 @@ async function doAppend(filePath, input) {
|
|
|
53
53
|
const affectedSpecs = input.affectedSpecs && input.affectedSpecs.length > 0 ? input.affectedSpecs : [input.specId];
|
|
54
54
|
const id = randomUUID();
|
|
55
55
|
const timestamp = new Date().toISOString();
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
sha,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
56
|
+
const bridgeSha = fastAppendGapEntry(filePath, id, timestamp, input.specId, input.description, severity, affectedSpecs);
|
|
57
|
+
if (typeof bridgeSha === 'string' && bridgeSha.length > 0) {
|
|
58
|
+
// The append path owns prevSha lookup internally. The response only needs
|
|
59
|
+
// the persisted entry identity and final sha.
|
|
60
|
+
return {
|
|
61
|
+
id,
|
|
62
|
+
timestamp,
|
|
63
|
+
specId: input.specId,
|
|
64
|
+
description: input.description,
|
|
65
|
+
severity,
|
|
66
|
+
affectedSpecs,
|
|
67
|
+
prevSha: '',
|
|
68
|
+
sha: bridgeSha,
|
|
69
|
+
};
|
|
73
70
|
}
|
|
74
71
|
await mkdir(dirname(filePath), { recursive: true });
|
|
75
|
-
// Read last entry's sha to chain
|
|
76
72
|
const lines = await readJsonlLines(filePath);
|
|
77
73
|
let prevSha = '';
|
|
78
74
|
if (lines.length > 0) {
|
|
@@ -138,16 +134,22 @@ export async function readGapsLog(projectId) {
|
|
|
138
134
|
* Verify the hash chain of the gaps log for a given project.
|
|
139
135
|
* Returns validation result with the index of the first broken entry (if any).
|
|
140
136
|
*/
|
|
141
|
-
export
|
|
137
|
+
export function verifyGapsLogChain(projectId) {
|
|
142
138
|
const filePath = gapsLogPath(projectId);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
return {
|
|
146
|
-
valid:
|
|
147
|
-
totalEntries:
|
|
148
|
-
brokenAt:
|
|
149
|
-
};
|
|
139
|
+
const bridgeResult = fastVerifyGapsChain(filePath);
|
|
140
|
+
if (isGapsLogChainResult(bridgeResult)) {
|
|
141
|
+
return Promise.resolve({
|
|
142
|
+
valid: bridgeResult.valid,
|
|
143
|
+
totalEntries: bridgeResult.totalEntries,
|
|
144
|
+
brokenAt: bridgeResult.brokenAt,
|
|
145
|
+
});
|
|
150
146
|
}
|
|
147
|
+
return verifyGapsLogChainFallback(filePath);
|
|
148
|
+
}
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Internal helpers
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
async function verifyGapsLogChainFallback(filePath) {
|
|
151
153
|
const lines = await readJsonlLines(filePath);
|
|
152
154
|
if (lines.length === 0) {
|
|
153
155
|
return { valid: true, totalEntries: 0, brokenAt: null };
|
|
@@ -165,11 +167,9 @@ export async function verifyGapsLogChain(projectId) {
|
|
|
165
167
|
catch {
|
|
166
168
|
return { valid: false, totalEntries: lines.length, brokenAt: i };
|
|
167
169
|
}
|
|
168
|
-
// Check prevSha chain
|
|
169
170
|
if (entry.prevSha !== prevSha) {
|
|
170
171
|
return { valid: false, totalEntries: lines.length, brokenAt: i };
|
|
171
172
|
}
|
|
172
|
-
// Verify sha
|
|
173
173
|
const { sha, ...rest } = entry;
|
|
174
174
|
const expectedSha = computeGapEntrySha(rest);
|
|
175
175
|
if (sha !== expectedSha) {
|
|
@@ -179,9 +179,15 @@ export async function verifyGapsLogChain(projectId) {
|
|
|
179
179
|
}
|
|
180
180
|
return { valid: true, totalEntries: lines.length, brokenAt: null };
|
|
181
181
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
function isGapsLogChainResult(value) {
|
|
183
|
+
if (typeof value !== 'object' || value === null) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
const candidate = value;
|
|
187
|
+
return (typeof candidate.valid === 'boolean' &&
|
|
188
|
+
typeof candidate.totalEntries === 'number' &&
|
|
189
|
+
(typeof candidate.brokenAt === 'number' || candidate.brokenAt === null));
|
|
190
|
+
}
|
|
185
191
|
async function readJsonlLines(filePath) {
|
|
186
192
|
let raw;
|
|
187
193
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planu/cli",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.4",
|
|
4
4
|
"description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -32,12 +32,12 @@
|
|
|
32
32
|
"packageName": "@planu/core"
|
|
33
33
|
},
|
|
34
34
|
"optionalDependencies": {
|
|
35
|
-
"@planu/core-darwin-arm64": "4.1.
|
|
36
|
-
"@planu/core-darwin-x64": "4.1.
|
|
37
|
-
"@planu/core-linux-arm64-gnu": "4.1.
|
|
38
|
-
"@planu/core-linux-arm64-musl": "4.1.
|
|
39
|
-
"@planu/core-linux-x64-gnu": "4.1.
|
|
40
|
-
"@planu/core-linux-x64-musl": "4.1.
|
|
35
|
+
"@planu/core-darwin-arm64": "4.1.4",
|
|
36
|
+
"@planu/core-darwin-x64": "4.1.4",
|
|
37
|
+
"@planu/core-linux-arm64-gnu": "4.1.4",
|
|
38
|
+
"@planu/core-linux-arm64-musl": "4.1.4",
|
|
39
|
+
"@planu/core-linux-x64-gnu": "4.1.4",
|
|
40
|
+
"@planu/core-linux-x64-musl": "4.1.4"
|
|
41
41
|
},
|
|
42
42
|
"engines": {
|
|
43
43
|
"node": ">=24.0.0"
|
|
@@ -143,6 +143,20 @@
|
|
|
143
143
|
"hono": ">=4.12.14",
|
|
144
144
|
"postcss": ">=8.5.10",
|
|
145
145
|
"fast-uri": ">=3.1.2"
|
|
146
|
+
},
|
|
147
|
+
"supportedArchitectures": {
|
|
148
|
+
"os": [
|
|
149
|
+
"darwin",
|
|
150
|
+
"linux"
|
|
151
|
+
],
|
|
152
|
+
"cpu": [
|
|
153
|
+
"arm64",
|
|
154
|
+
"x64"
|
|
155
|
+
],
|
|
156
|
+
"libc": [
|
|
157
|
+
"glibc",
|
|
158
|
+
"musl"
|
|
159
|
+
]
|
|
146
160
|
}
|
|
147
161
|
},
|
|
148
162
|
"devDependencies": {
|