@planu/cli 4.1.3 → 4.2.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 +23 -1
- package/dist/engine/code-scanner/layer-scanner.js +29 -116
- package/dist/engine/core-bridge.d.ts +6 -6
- 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/onboarding/new-project-resolver.d.ts +7 -0
- package/dist/engine/onboarding/new-project-resolver.js +265 -0
- package/dist/engine/reviewer-tokens/signer.js +4 -22
- package/dist/engine/scan-project/module-discoverer.js +9 -11
- package/dist/engine/spec-migrator/strict-planu-cleanup.js +2 -1
- 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/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/technology-selection-store.d.ts +5 -0
- package/dist/storage/technology-selection-store.js +42 -0
- package/dist/tools/create-spec.js +23 -3
- package/dist/tools/facilitate.js +39 -29
- package/dist/tools/init-project/handler.js +59 -12
- package/dist/tools/register-spec-tools/core-spec-tools.js +33 -1
- package/dist/tools/tool-registry/core-tools.js +35 -1
- package/dist/tools/update-status/batch.js +4 -1
- package/dist/types/facilitator.d.ts +13 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/project/inputs.d.ts +12 -0
- package/dist/types/technology-selection.d.ts +77 -0
- package/dist/types/technology-selection.js +3 -0
- package/package.json +22 -8
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// engine/onboarding/new-project-resolver.ts — SPEC-1021 safe new-project onboarding
|
|
2
|
+
import { mkdir } from 'node:fs/promises';
|
|
3
|
+
import path, { resolve } from 'node:path';
|
|
4
|
+
import { findDocsEntry, loadDocsRegistry } from '../web-fetcher/registry-loader.js';
|
|
5
|
+
const NONE_LABELS = new Set(['none', 'no framework', 'ninguno', 'sin framework', 'n/a']);
|
|
6
|
+
const UNSURE_LABELS = new Set(['not sure', 'unsure', 'no estoy seguro', 'not-sure']);
|
|
7
|
+
function answer(input, key) {
|
|
8
|
+
const value = input.clarificationAnswers?.[key] ?? input.clarificationAnswers?.[key.toLowerCase()];
|
|
9
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
10
|
+
}
|
|
11
|
+
function valueOrAnswer(explicit, input, key) {
|
|
12
|
+
if (typeof explicit === 'string' && explicit.trim().length > 0) {
|
|
13
|
+
return explicit.trim();
|
|
14
|
+
}
|
|
15
|
+
return answer(input, key);
|
|
16
|
+
}
|
|
17
|
+
function normalizeNullableTech(value) {
|
|
18
|
+
if (value === null) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const trimmed = value?.trim();
|
|
22
|
+
if (!trimmed) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const lower = trimmed.toLowerCase();
|
|
26
|
+
if (NONE_LABELS.has(lower)) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (UNSURE_LABELS.has(lower)) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return trimmed;
|
|
33
|
+
}
|
|
34
|
+
export function slugifyAppName(input) {
|
|
35
|
+
return input
|
|
36
|
+
.trim()
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
39
|
+
.replace(/[-.]{2,}/g, '-')
|
|
40
|
+
.replace(/^[-.]+|[-.]+$/g, '');
|
|
41
|
+
}
|
|
42
|
+
export function validateAppSlug(slug) {
|
|
43
|
+
if (!slug) {
|
|
44
|
+
return 'App folder is required.';
|
|
45
|
+
}
|
|
46
|
+
if (slug === '.' || slug === '..' || slug.includes('/') || slug.includes('\\')) {
|
|
47
|
+
return 'App folder must be a simple folder name, not a path.';
|
|
48
|
+
}
|
|
49
|
+
if (slug.includes('..')) {
|
|
50
|
+
return 'App folder cannot contain path traversal.';
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
function isWinPath(value) {
|
|
55
|
+
return /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith('\\\\');
|
|
56
|
+
}
|
|
57
|
+
export function resolveNewProjectPath(parentWorkspacePath, appSlug) {
|
|
58
|
+
const slugError = validateAppSlug(appSlug);
|
|
59
|
+
if (slugError) {
|
|
60
|
+
throw new Error(slugError);
|
|
61
|
+
}
|
|
62
|
+
if (isWinPath(parentWorkspacePath)) {
|
|
63
|
+
return path.win32.normalize(path.win32.join(parentWorkspacePath, appSlug));
|
|
64
|
+
}
|
|
65
|
+
return resolve(parentWorkspacePath, appSlug);
|
|
66
|
+
}
|
|
67
|
+
function buildQuestion(header, question, recommended, alternate, fallback) {
|
|
68
|
+
return {
|
|
69
|
+
header,
|
|
70
|
+
question,
|
|
71
|
+
multiSelect: false,
|
|
72
|
+
options: [
|
|
73
|
+
{ label: `${recommended} (Recommended)`, description: `Use "${recommended}".` },
|
|
74
|
+
{ label: alternate, description: `Use "${alternate}".` },
|
|
75
|
+
{ label: fallback, description: `Use "${fallback}" or provide another value.` },
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function buildRequiredQuestions(input, resolved) {
|
|
80
|
+
const suggestedSlug = slugifyAppName(input.appName ?? input.description).slice(0, 48) || 'new-app';
|
|
81
|
+
const specs = [
|
|
82
|
+
[
|
|
83
|
+
!resolved.parentWorkspacePath,
|
|
84
|
+
buildQuestion('Parent', 'Which parent workspace should contain the new app folder?', process.cwd(), input.projectPath ?? './', 'Custom path'),
|
|
85
|
+
],
|
|
86
|
+
[
|
|
87
|
+
!resolved.appSlug,
|
|
88
|
+
buildQuestion('App Folder', 'What should the new app folder be called?', suggestedSlug, 'api', 'Custom folder'),
|
|
89
|
+
],
|
|
90
|
+
[
|
|
91
|
+
!resolved.projectType,
|
|
92
|
+
buildQuestion('Project Type', 'What type of project are you creating?', 'api', 'web app', 'Custom type'),
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
!resolved.platform,
|
|
96
|
+
buildQuestion('Platform', 'Where will this project run?', 'server', 'web', 'Custom platform'),
|
|
97
|
+
],
|
|
98
|
+
[
|
|
99
|
+
!resolved.languageAnswered,
|
|
100
|
+
buildQuestion('Language', 'Which programming language should this project use?', 'Not sure', 'Use project/team default', 'Custom language'),
|
|
101
|
+
],
|
|
102
|
+
[
|
|
103
|
+
!resolved.frameworkAnswered,
|
|
104
|
+
buildQuestion('Framework', 'Which framework should this project use?', 'Not sure', 'No framework', 'Custom framework'),
|
|
105
|
+
],
|
|
106
|
+
];
|
|
107
|
+
return specs.filter(([missing]) => missing).map(([, question]) => question);
|
|
108
|
+
}
|
|
109
|
+
function buildCreateDirectoryQuestion() {
|
|
110
|
+
return {
|
|
111
|
+
header: 'Create Dir',
|
|
112
|
+
question: 'May Planu create the project folder if it does not exist?',
|
|
113
|
+
multiSelect: false,
|
|
114
|
+
options: [
|
|
115
|
+
{ label: 'yes (Recommended)', description: 'Create only the resolved app folder.' },
|
|
116
|
+
{ label: 'no', description: 'Do not create folders; I will create it myself.' },
|
|
117
|
+
{ label: 'already exists', description: 'The project folder already exists.' },
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export function buildNewProjectQuestions(input, resolved) {
|
|
122
|
+
const questions = buildRequiredQuestions(input, resolved);
|
|
123
|
+
if (!resolved.createDirectoryAnswered) {
|
|
124
|
+
questions.push(buildCreateDirectoryQuestion());
|
|
125
|
+
}
|
|
126
|
+
return questions;
|
|
127
|
+
}
|
|
128
|
+
async function verifyDocs(names) {
|
|
129
|
+
const registry = await loadDocsRegistry();
|
|
130
|
+
return names
|
|
131
|
+
.filter((name) => name.trim().length > 0)
|
|
132
|
+
.map((name) => {
|
|
133
|
+
const entry = findDocsEntry(name, registry);
|
|
134
|
+
if (entry) {
|
|
135
|
+
return { name, url: entry.base, status: 'verified' };
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
name,
|
|
139
|
+
url: '',
|
|
140
|
+
status: 'unverified',
|
|
141
|
+
reason: 'No official docs registry entry found.',
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function collectResolvedFields(input) {
|
|
146
|
+
const parentWorkspacePath = valueOrAnswer(input.parentWorkspacePath, input, 'Parent') ??
|
|
147
|
+
valueOrAnswer(input.projectPath, input, 'Parent');
|
|
148
|
+
const appName = valueOrAnswer(input.appName, input, 'App Folder');
|
|
149
|
+
const appSlug = valueOrAnswer(input.appSlug, input, 'App Folder') ??
|
|
150
|
+
(appName ? slugifyAppName(appName) : undefined);
|
|
151
|
+
const projectType = valueOrAnswer(input.projectType, input, 'Project Type');
|
|
152
|
+
const platform = valueOrAnswer(input.platform, input, 'Platform');
|
|
153
|
+
const rawLanguage = valueOrAnswer(input.language, input, 'Language');
|
|
154
|
+
const rawFramework = input.framework === null ? null : valueOrAnswer(input.framework, input, 'Framework');
|
|
155
|
+
const rawDatabase = input.database === null ? null : valueOrAnswer(input.database, input, 'Database');
|
|
156
|
+
const language = normalizeNullableTech(rawLanguage);
|
|
157
|
+
const framework = normalizeNullableTech(rawFramework);
|
|
158
|
+
const database = normalizeNullableTech(rawDatabase);
|
|
159
|
+
const createDirAnswer = answer(input, 'Create Dir')?.toLowerCase();
|
|
160
|
+
const languageAnswered = rawLanguage !== undefined;
|
|
161
|
+
const frameworkAnswered = rawFramework !== undefined;
|
|
162
|
+
const createDirectoryAnswered = input.createDirectory !== undefined || createDirAnswer !== undefined;
|
|
163
|
+
const createDirectory = input.createDirectory === true ||
|
|
164
|
+
createDirAnswer === 'yes' ||
|
|
165
|
+
createDirAnswer === 'yes (recommended)' ||
|
|
166
|
+
createDirAnswer === 'already exists';
|
|
167
|
+
const errors = appSlug ? [validateAppSlug(appSlug)].filter((e) => Boolean(e)) : [];
|
|
168
|
+
const resolvedProjectPath = parentWorkspacePath && appSlug && errors.length === 0
|
|
169
|
+
? resolveNewProjectPath(parentWorkspacePath, appSlug)
|
|
170
|
+
: undefined;
|
|
171
|
+
return {
|
|
172
|
+
parentWorkspacePath,
|
|
173
|
+
appName,
|
|
174
|
+
appSlug,
|
|
175
|
+
projectType,
|
|
176
|
+
platform,
|
|
177
|
+
language: language ?? undefined,
|
|
178
|
+
framework,
|
|
179
|
+
database,
|
|
180
|
+
languageAnswered,
|
|
181
|
+
frameworkAnswered,
|
|
182
|
+
createDirectoryAnswered,
|
|
183
|
+
createDirectory,
|
|
184
|
+
resolvedProjectPath,
|
|
185
|
+
errors,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function isReady(fields) {
|
|
189
|
+
return Boolean(fields.parentWorkspacePath &&
|
|
190
|
+
fields.appSlug &&
|
|
191
|
+
fields.projectType &&
|
|
192
|
+
fields.platform &&
|
|
193
|
+
fields.languageAnswered &&
|
|
194
|
+
fields.frameworkAnswered &&
|
|
195
|
+
fields.createDirectoryAnswered &&
|
|
196
|
+
fields.createDirectory &&
|
|
197
|
+
fields.resolvedProjectPath &&
|
|
198
|
+
fields.errors.length === 0);
|
|
199
|
+
}
|
|
200
|
+
async function buildTechnologyContract(input, fields) {
|
|
201
|
+
const resolvedProjectPath = fields.resolvedProjectPath;
|
|
202
|
+
if (!resolvedProjectPath) {
|
|
203
|
+
throw new Error('Resolved project path is required to build a technology contract.');
|
|
204
|
+
}
|
|
205
|
+
const docNames = [fields.language, fields.framework, fields.database].filter((name) => typeof name === 'string' && name.length > 0);
|
|
206
|
+
const officialDocs = await verifyDocs(docNames);
|
|
207
|
+
return {
|
|
208
|
+
mode: 'new_project',
|
|
209
|
+
projectPath: resolvedProjectPath,
|
|
210
|
+
parentWorkspacePath: fields.parentWorkspacePath,
|
|
211
|
+
appName: fields.appName,
|
|
212
|
+
appSlug: fields.appSlug,
|
|
213
|
+
projectType: fields.projectType,
|
|
214
|
+
platform: fields.platform,
|
|
215
|
+
language: fields.language,
|
|
216
|
+
framework: fields.framework ?? null,
|
|
217
|
+
database: fields.database ?? null,
|
|
218
|
+
source: 'user-confirmed',
|
|
219
|
+
evidence: [
|
|
220
|
+
`User requested a new project: ${input.description}`,
|
|
221
|
+
`Resolved project folder: ${resolvedProjectPath}`,
|
|
222
|
+
],
|
|
223
|
+
officialDocs,
|
|
224
|
+
verifiedAt: new Date().toISOString(),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
export async function resolveNewProjectOnboarding(input) {
|
|
228
|
+
const fields = collectResolvedFields(input);
|
|
229
|
+
if (!isReady(fields)) {
|
|
230
|
+
return {
|
|
231
|
+
ready: false,
|
|
232
|
+
parentWorkspacePath: fields.parentWorkspacePath,
|
|
233
|
+
resolvedProjectPath: fields.resolvedProjectPath,
|
|
234
|
+
appSlug: fields.appSlug,
|
|
235
|
+
questions: buildNewProjectQuestions(input, {
|
|
236
|
+
parentWorkspacePath: fields.parentWorkspacePath,
|
|
237
|
+
appSlug: fields.appSlug,
|
|
238
|
+
projectType: fields.projectType,
|
|
239
|
+
platform: fields.platform,
|
|
240
|
+
language: fields.language,
|
|
241
|
+
framework: fields.framework,
|
|
242
|
+
languageAnswered: fields.languageAnswered,
|
|
243
|
+
frameworkAnswered: fields.frameworkAnswered,
|
|
244
|
+
createDirectoryAnswered: fields.createDirectoryAnswered,
|
|
245
|
+
}),
|
|
246
|
+
errors: fields.errors,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
const resolvedProjectPath = fields.resolvedProjectPath;
|
|
250
|
+
if (!resolvedProjectPath) {
|
|
251
|
+
throw new Error('Resolved project path is required before directory creation.');
|
|
252
|
+
}
|
|
253
|
+
await mkdir(resolvedProjectPath, { recursive: true });
|
|
254
|
+
const contract = await buildTechnologyContract(input, fields);
|
|
255
|
+
return {
|
|
256
|
+
ready: true,
|
|
257
|
+
parentWorkspacePath: fields.parentWorkspacePath,
|
|
258
|
+
resolvedProjectPath: fields.resolvedProjectPath,
|
|
259
|
+
appSlug: fields.appSlug,
|
|
260
|
+
questions: [],
|
|
261
|
+
contract,
|
|
262
|
+
errors: fields.errors,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
//# sourceMappingURL=new-project-resolver.js.map
|
|
@@ -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) {
|
|
@@ -171,7 +171,8 @@ export async function validateStrictPlanuLayout(projectPath) {
|
|
|
171
171
|
for (const entry of entries) {
|
|
172
172
|
const full = join(planuDir, entry);
|
|
173
173
|
const isDir = await pathIsDirectory(full);
|
|
174
|
-
if ((isDir && !isCanonicalPlanuRootDir(entry)) ||
|
|
174
|
+
if ((isDir && !isCanonicalPlanuRootDir(entry)) ||
|
|
175
|
+
(!isDir && !isCanonicalPlanuRootFile(entry))) {
|
|
175
176
|
offenders.push(relative(projectPath, full));
|
|
176
177
|
}
|
|
177
178
|
}
|
|
@@ -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/dist/storage/index.d.ts
CHANGED
|
@@ -19,5 +19,6 @@ export * as conventionBaselineStore from './convention-baseline.js';
|
|
|
19
19
|
export * as lessonsStore from './lessons-store.js';
|
|
20
20
|
export * as actualsStore from './actuals-store.js';
|
|
21
21
|
export * as approvalStore from './approval-store.js';
|
|
22
|
+
export * as technologySelectionStore from './technology-selection-store.js';
|
|
22
23
|
export * from './skill-registry-storage.js';
|
|
23
24
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/storage/index.js
CHANGED
|
@@ -20,5 +20,6 @@ export * as conventionBaselineStore from './convention-baseline.js';
|
|
|
20
20
|
export * as lessonsStore from './lessons-store.js';
|
|
21
21
|
export * as actualsStore from './actuals-store.js';
|
|
22
22
|
export * as approvalStore from './approval-store.js';
|
|
23
|
+
export * as technologySelectionStore from './technology-selection-store.js';
|
|
23
24
|
export * from './skill-registry-storage.js';
|
|
24
25
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { TechnologySelectionContract } from '../types/index.js';
|
|
2
|
+
export declare const TECHNOLOGY_SELECTION_RELATIVE_PATH = ".planu/technology-selection.json";
|
|
3
|
+
export declare function readTechnologySelectionContract(projectPath: string): Promise<TechnologySelectionContract | null>;
|
|
4
|
+
export declare function writeTechnologySelectionContract(projectPath: string, contract: TechnologySelectionContract): Promise<void>;
|
|
5
|
+
//# sourceMappingURL=technology-selection-store.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// storage/technology-selection-store.ts — SPEC-1021 project-local technology contract
|
|
2
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { writeJsonSafe } from '../engine/safety/atomic-writer.js';
|
|
5
|
+
import { safeJsonParse } from './base-store.js';
|
|
6
|
+
import { withFileLock } from './file-mutex.js';
|
|
7
|
+
export const TECHNOLOGY_SELECTION_RELATIVE_PATH = '.planu/technology-selection.json';
|
|
8
|
+
function contractPath(projectPath) {
|
|
9
|
+
return join(projectPath, TECHNOLOGY_SELECTION_RELATIVE_PATH);
|
|
10
|
+
}
|
|
11
|
+
function isStringArray(value) {
|
|
12
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
13
|
+
}
|
|
14
|
+
function isTechnologySelectionContract(value) {
|
|
15
|
+
if (value === null || typeof value !== 'object') {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const rec = value;
|
|
19
|
+
return ((rec.mode === 'existing_project' || rec.mode === 'new_project') &&
|
|
20
|
+
typeof rec.projectPath === 'string' &&
|
|
21
|
+
(rec.source === 'detected' ||
|
|
22
|
+
rec.source === 'user-confirmed' ||
|
|
23
|
+
rec.source === 'suggested-confirmed') &&
|
|
24
|
+
isStringArray(rec.evidence) &&
|
|
25
|
+
Array.isArray(rec.officialDocs));
|
|
26
|
+
}
|
|
27
|
+
export async function readTechnologySelectionContract(projectPath) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = await readFile(contractPath(projectPath), 'utf-8');
|
|
30
|
+
const parsed = safeJsonParse(raw, null);
|
|
31
|
+
return isTechnologySelectionContract(parsed) ? parsed : null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function writeTechnologySelectionContract(projectPath, contract) {
|
|
38
|
+
const filePath = contractPath(projectPath);
|
|
39
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
40
|
+
await withFileLock(filePath, () => writeJsonSafe(filePath, contract));
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=technology-selection-store.js.map
|