periderm-cli 0.1.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.
@@ -0,0 +1,53 @@
1
+ import chalk from "chalk";
2
+ const ember = chalk.hex("#FF4D00");
3
+ const dim = chalk.hex("#9CA3AF");
4
+ const hairline = dim("─".repeat(72));
5
+ export function printTerminal(r) {
6
+ const verdictLabel = r.verdict === "launch_ready"
7
+ ? chalk.bgGreen.black.bold(" LAUNCH READY ")
8
+ : r.verdict === "hold"
9
+ ? chalk.bgYellow.black.bold(" HOLD ")
10
+ : chalk.bgHex("#FF4D00").white.bold(" DO NOT LAUNCH ");
11
+ console.info("");
12
+ console.info(hairline);
13
+ console.info("");
14
+ console.info(chalk.bold("Periderm CLI Verdict"));
15
+ console.info(`Launch Confidence: ${score(r.scores.confidence)}/100`);
16
+ console.info(`Reality Score: ${score(r.scores.reality)}/100`);
17
+ console.info(`Perceived Performance: ${score(r.scores.perceived)}/100`);
18
+ console.info("");
19
+ console.info(hairline);
20
+ console.info("");
21
+ console.info(`Recommendation: ${verdictLabel}`);
22
+ console.info(dim("Top embarrassment risks:"));
23
+ const seenMessages = new Set();
24
+ const top = [...r.findings]
25
+ .sort((a, b) => rank(b.severity) - rank(a.severity))
26
+ .filter((f) => {
27
+ if (seenMessages.has(f.message))
28
+ return false;
29
+ seenMessages.add(f.message);
30
+ return true;
31
+ })
32
+ .slice(0, 8);
33
+ top.forEach((f, i) => {
34
+ console.info(`${dim(String(i + 1) + ".")} ${f.message}`);
35
+ });
36
+ const total = r.counts.critical + r.counts.high + r.counts.medium + r.counts.low;
37
+ console.info("");
38
+ console.info(hairline);
39
+ console.info("");
40
+ console.info(`${ember("▸")} ${total} findings · full report: ${dim(".periderm/last-report.md")}`);
41
+ console.info(`${ember("$")}`);
42
+ console.info("");
43
+ }
44
+ function rank(s) {
45
+ return s === "critical" ? 4 : s === "high" ? 3 : s === "medium" ? 2 : 1;
46
+ }
47
+ function score(n) {
48
+ if (n >= 85)
49
+ return chalk.green(String(n));
50
+ if (n >= 65)
51
+ return chalk.yellow(String(n));
52
+ return ember(String(n));
53
+ }
@@ -0,0 +1,42 @@
1
+ /** Category tags used by checks that contribute to Reality / Perceived scores. */
2
+ const REALITY_CATEGORIES = new Set([
3
+ "Reality & Resilience",
4
+ "Data Integrity",
5
+ "Routing & Navigation",
6
+ ]);
7
+ const PERCEIVED_CATEGORIES = new Set([
8
+ "Loading & Transitions",
9
+ "Error Experience",
10
+ "Accessibility",
11
+ ]);
12
+ export function computeScores(findings) {
13
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 };
14
+ for (const f of findings)
15
+ counts[f.severity]++;
16
+ const confidence = clamp(100 - (counts.critical * 15 + counts.high * 6 + counts.medium * 2 + counts.low));
17
+ const reality = clamp(100 - weightedSubset(findings, (f) => REALITY_CATEGORIES.has(f.category)));
18
+ const perceived = clamp(100 - weightedSubset(findings, (f) => PERCEIVED_CATEGORIES.has(f.category)));
19
+ return { confidence, reality, perceived };
20
+ }
21
+ export function computeVerdict(c, confidence) {
22
+ const verdict = c.critical > 0 ? "blocked"
23
+ : confidence < 75 ? "hold"
24
+ : "launch_ready";
25
+ return { score: confidence, verdict };
26
+ }
27
+ function clamp(n) {
28
+ return Math.max(0, Math.min(100, Math.round(n)));
29
+ }
30
+ function weightedSubset(findings, pred) {
31
+ let pen = 0;
32
+ for (const f of findings) {
33
+ if (!pred(f))
34
+ continue;
35
+ pen +=
36
+ f.severity === "critical" ? 20
37
+ : f.severity === "high" ? 10
38
+ : f.severity === "medium" ? 4
39
+ : 1;
40
+ }
41
+ return pen;
42
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Deep AI review — Scale/Unlimited only.
3
+ * Uses Groq with structured JSON tool calls (no native function calling required).
4
+ */
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ import fg from "fast-glob";
8
+ const MAX_ITERATIONS = 8;
9
+ const MAX_FILE_CHARS = 12_000;
10
+ const MODEL = "llama-3.3-70b-versatile";
11
+ function stripCodeFences(text) {
12
+ const m = text.match(/```(?:json)?\s*([\s\S]*?)```/);
13
+ return (m?.[1] ?? text).trim();
14
+ }
15
+ async function groqJson(apiKey, system, user) {
16
+ const res = await fetch("https://api.groq.com/openai/v1/chat/completions", {
17
+ method: "POST",
18
+ headers: {
19
+ Authorization: `Bearer ${apiKey}`,
20
+ "Content-Type": "application/json",
21
+ },
22
+ body: JSON.stringify({
23
+ model: MODEL,
24
+ temperature: 0.2,
25
+ response_format: { type: "json_object" },
26
+ messages: [
27
+ { role: "system", content: system },
28
+ { role: "user", content: user },
29
+ ],
30
+ }),
31
+ });
32
+ if (!res.ok)
33
+ throw new Error(`Groq API error ${res.status}: ${(await res.text()).slice(0, 200)}`);
34
+ const data = (await res.json());
35
+ return data.choices?.[0]?.message?.content ?? "{}";
36
+ }
37
+ async function readFileSafe(root, rel) {
38
+ const safe = rel.replace(/^(\.\/|\.\.\/)+/, "").replace(/\.\./g, "");
39
+ const abs = path.join(root, safe);
40
+ if (!abs.startsWith(root))
41
+ return "Error: path outside project root";
42
+ try {
43
+ const content = await fs.readFile(abs, "utf8");
44
+ return content.length > MAX_FILE_CHARS
45
+ ? content.slice(0, MAX_FILE_CHARS) + "\n…[truncated]"
46
+ : content;
47
+ }
48
+ catch {
49
+ return "Error: file not found or unreadable";
50
+ }
51
+ }
52
+ async function searchFiles(root, query) {
53
+ const files = await fg(["**/*.{ts,tsx,js,jsx,md,html}"], {
54
+ cwd: root,
55
+ absolute: false,
56
+ ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**"],
57
+ });
58
+ const q = query.toLowerCase();
59
+ const hits = [];
60
+ for (const f of files.slice(0, 400)) {
61
+ if (f.toLowerCase().includes(q))
62
+ hits.push(f);
63
+ if (hits.length >= 15)
64
+ break;
65
+ }
66
+ if (hits.length === 0) {
67
+ for (const f of files.slice(0, 200)) {
68
+ try {
69
+ const content = await fs.readFile(path.join(root, f), "utf8");
70
+ if (content.toLowerCase().includes(q))
71
+ hits.push(f);
72
+ if (hits.length >= 10)
73
+ break;
74
+ }
75
+ catch {
76
+ /* skip */
77
+ }
78
+ }
79
+ }
80
+ return hits.length ? hits.join("\n") : "No matches.";
81
+ }
82
+ function parseAction(raw) {
83
+ try {
84
+ return JSON.parse(stripCodeFences(raw));
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ export async function runDeepReview(root, scan, apiKey, onStatus) {
91
+ const system = `You are Periderm's deep launch reviewer. Respond with JSON only.
92
+
93
+ Existing static scan found ${scan.findings.length} issues. Your job: find ADDITIONAL nuanced launch risks the deterministic scanner missed — legal/UX/business logic edge cases, contradictions, deceptive flows, subtle security gaps.
94
+
95
+ Tools (respond with {"action":"tool",...}):
96
+ - read_file: {"action":"tool","tool":"read_file","path":"src/routes/privacy.tsx"}
97
+ - search_files: {"action":"tool","tool":"search_files","query":"gtag"}
98
+
99
+ When finished: {"action":"done","summary":"...","findings":[{"message":"...","severity":"high|medium|low|critical","file":"path","why":"...","fix":"..."}]}
100
+
101
+ Rules: max 5 new findings, be specific, cite files, no hallucinated files.`;
102
+ const context = {
103
+ project: scan.projectName,
104
+ verdict: scan.verdict,
105
+ scores: scan.scores,
106
+ existingFindings: scan.findings.slice(0, 40).map((f) => ({
107
+ id: f.id,
108
+ severity: f.severity,
109
+ message: f.message,
110
+ file: f.file,
111
+ })),
112
+ };
113
+ let transcript = `Project context:\n${JSON.stringify(context, null, 2)}\n\nBegin investigation. Use tools before concluding.`;
114
+ const toolLog = [];
115
+ for (let i = 0; i < MAX_ITERATIONS; i++) {
116
+ onStatus?.(`Deep review · pass ${i + 1}/${MAX_ITERATIONS}…`);
117
+ const raw = await groqJson(apiKey, system, transcript);
118
+ const action = parseAction(raw);
119
+ if (!action) {
120
+ transcript += `\n\nInvalid JSON. Respond with valid JSON only.\n`;
121
+ continue;
122
+ }
123
+ if (action.action === "done") {
124
+ const findings = (action.findings ?? []).slice(0, 8).map((f, idx) => ({
125
+ id: `deep-review-${idx + 1}`,
126
+ category: "Deep Review (AI)",
127
+ severity: (["critical", "high", "medium", "low"].includes(f.severity)
128
+ ? f.severity
129
+ : "medium"),
130
+ file: f.file ?? "project",
131
+ line: 1,
132
+ message: f.message,
133
+ why: f.why,
134
+ fix: f.fix,
135
+ aiPrompt: f.fix,
136
+ }));
137
+ const md = [
138
+ "## Deep review (AI agent)",
139
+ "",
140
+ action.summary,
141
+ "",
142
+ ...(findings.length
143
+ ? findings.map((f) => `### ${f.severity.toUpperCase()} — ${f.message}\n\n- **File:** \`${f.file}\`\n- **Why:** ${f.why}\n- **Fix:** ${f.fix}\n`)
144
+ : ["No additional findings beyond the static scan."]),
145
+ "",
146
+ `_Agent tools used: ${toolLog.length ? toolLog.join(", ") : "none"}_`,
147
+ ].join("\n");
148
+ return { markdown: md, findings };
149
+ }
150
+ if (action.action === "tool" && action.tool === "read_file" && action.path) {
151
+ onStatus?.(`Reading ${action.path}…`);
152
+ const content = await readFileSafe(root, action.path);
153
+ toolLog.push(`read:${action.path}`);
154
+ transcript += `\n\nTool result (read_file ${action.path}):\n\`\`\`\n${content}\n\`\`\`\n`;
155
+ continue;
156
+ }
157
+ if (action.action === "tool" && action.tool === "search_files" && action.query) {
158
+ onStatus?.(`Searching for "${action.query}"…`);
159
+ const hits = await searchFiles(root, action.query);
160
+ toolLog.push(`search:${action.query}`);
161
+ transcript += `\n\nTool result (search_files "${action.query}"):\n${hits}\n`;
162
+ continue;
163
+ }
164
+ transcript += `\n\nUnknown action. Use read_file, search_files, or done.\n`;
165
+ }
166
+ return {
167
+ markdown: "## Deep review (AI agent)\n\nAgent reached iteration limit without finishing. Re-run or narrow scope.",
168
+ findings: [],
169
+ };
170
+ }
@@ -0,0 +1,22 @@
1
+ import { parse } from "@babel/parser";
2
+ export function parseSource(source, filename) {
3
+ try {
4
+ return parse(source, {
5
+ sourceType: "module",
6
+ sourceFilename: filename,
7
+ allowImportExportEverywhere: true,
8
+ allowReturnOutsideFunction: true,
9
+ errorRecovery: true,
10
+ plugins: [
11
+ "typescript",
12
+ "jsx",
13
+ "decorators-legacy",
14
+ "topLevelAwait",
15
+ "importMeta",
16
+ ],
17
+ });
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }