javi-forge 1.26.0 → 1.28.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/ci-local/hooks/commit-msg +7 -0
- package/ci-local/hooks/pre-commit +8 -0
- package/ci-local/hooks/pre-push +8 -0
- package/dist/cli/dispatch/ci.js +1 -1
- package/dist/cli/dispatch/simple-renderers.js +1 -1
- package/dist/cli/dispatch/skills-cmd.js +9 -1
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.js +12 -0
- package/dist/commands/ci.js +5 -1
- package/dist/commands/doctor.js +9 -0
- package/dist/commands/init/steps/ghagga.d.ts +3 -4
- package/dist/commands/init/steps/ghagga.js +5 -15
- package/dist/commands/plugin.d.ts +4 -2
- package/dist/commands/plugin.js +4 -4
- package/dist/commands/skills/analysis.js +31 -2
- package/dist/commands/skills/benchmark.js +10 -0
- package/dist/commands/skills/constants.d.ts +5 -0
- package/dist/commands/skills/constants.js +5 -0
- package/dist/commands/skills/parsing.d.ts +18 -3
- package/dist/commands/skills/parsing.js +29 -3
- package/dist/commands/skills/scoring.d.ts +7 -6
- package/dist/commands/skills/scoring.js +31 -1
- package/dist/lib/agent-skills.d.ts +1 -0
- package/dist/lib/agent-skills.js +155 -1
- package/dist/lib/auto-skill-install.d.ts +5 -0
- package/dist/lib/auto-skill-install.js +40 -2
- package/dist/lib/context.d.ts +22 -0
- package/dist/lib/context.js +120 -79
- package/dist/lib/plugin.d.ts +1 -0
- package/dist/lib/plugin.js +58 -1
- package/dist/lib/safe-read.d.ts +62 -0
- package/dist/lib/safe-read.js +221 -0
- package/dist/lib/security-analysis.d.ts +19 -2
- package/dist/lib/security-analysis.js +65 -13
- package/dist/lib/skill-install-gate.d.ts +31 -0
- package/dist/lib/skill-install-gate.js +30 -0
- package/dist/lib/skill-scanner.d.ts +65 -1
- package/dist/lib/skill-scanner.js +307 -4
- package/dist/types/index.d.ts +18 -0
- package/dist/ui/AutoSkills.d.ts +3 -1
- package/dist/ui/AutoSkills.js +17 -2
- package/dist/ui/Plugin.d.ts +3 -1
- package/dist/ui/Plugin.js +4 -4
- package/dist/ui/Skills.js +12 -7
- package/package.json +9 -5
- package/templates/github/ghagga-review.yml +0 -30
package/dist/lib/agent-skills.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "fs-extra";
|
|
3
3
|
import { AGENT_SKILLS_MANIFEST_FILE, PLUGIN_MANIFEST_FILE, PLUGINS_DIR, } from "../constants.js";
|
|
4
|
+
import { evaluateInstallGate } from "./skill-install-gate.js";
|
|
5
|
+
import { formatBatchReport, scanSkillsWithCoverage } from "./skill-scanner.js";
|
|
4
6
|
// ── Conversion ─────────────────────────────────────────────────────────────
|
|
5
7
|
/**
|
|
6
8
|
* Convert a javi-forge PluginManifest to an Agent Skills spec manifest.
|
|
@@ -68,7 +70,7 @@ export async function exportPluginAsAgentSkills(name) {
|
|
|
68
70
|
* Reads skills.json, converts to plugin.json, copies to plugins dir.
|
|
69
71
|
*/
|
|
70
72
|
export async function importAgentSkillsPackage(sourceDir, options = {}) {
|
|
71
|
-
const { dryRun = false } = options;
|
|
73
|
+
const { dryRun = false, force = false } = options;
|
|
72
74
|
const skillsPath = path.join(sourceDir, AGENT_SKILLS_MANIFEST_FILE);
|
|
73
75
|
if (!(await fs.pathExists(skillsPath))) {
|
|
74
76
|
return { success: false, error: "skills.json not found" };
|
|
@@ -88,11 +90,130 @@ export async function importAgentSkillsPackage(sourceDir, options = {}) {
|
|
|
88
90
|
error: "skills.json missing required fields (name, version, description)",
|
|
89
91
|
};
|
|
90
92
|
}
|
|
93
|
+
// R1-002: `name` becomes the import destination (`destDir = path.join(
|
|
94
|
+
// PLUGINS_DIR, name)`) and is handed to `fs.remove` + `fs.copy` below with
|
|
95
|
+
// NO path validation — a hostile skills.json name (e.g. `"../../.bashrc"`,
|
|
96
|
+
// an absolute path, or a separator-bearing name) would delete/copy
|
|
97
|
+
// ARBITRARY paths outside PLUGINS_DIR. The gate validates declared
|
|
98
|
+
// `skills[].path` escapes but never the name that determines the
|
|
99
|
+
// write/delete destination. Same-trust note: `name` is attacker-influenced
|
|
100
|
+
// when installing from a registry package, not a typed-in label — refuse
|
|
101
|
+
// BEFORE any `fs.remove`/`fs.copy`/`destDir` use, so an existing install
|
|
102
|
+
// is preserved (style-consistent with the gate's manifest-integrity
|
|
103
|
+
// refusals, block-level, force never lifts).
|
|
91
104
|
const pluginName = agentManifest.name;
|
|
105
|
+
// R1-F2-N2: `name` is attacker-influenced JSON — it need not be a string at
|
|
106
|
+
// all (`{"name": 123}` is truthy, so it sails past the required-fields
|
|
107
|
+
// check above). Guard the type BEFORE the `.trim()` check: a non-string
|
|
108
|
+
// name refuses cleanly with the same manifest-integrity message instead of
|
|
109
|
+
// throwing a TypeError out of `.trim()` (which propagated to the UI as a
|
|
110
|
+
// "Fatal error").
|
|
111
|
+
if (typeof pluginName !== "string") {
|
|
112
|
+
return {
|
|
113
|
+
success: false,
|
|
114
|
+
error: `skillguard: install refused — invalid manifest name "${pluginName}" (manifest-integrity, force never lifts)`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (pluginName.trim() === "" ||
|
|
118
|
+
pluginName === "." ||
|
|
119
|
+
pluginName.includes("..") ||
|
|
120
|
+
pluginName.includes("/") ||
|
|
121
|
+
pluginName.includes("\\") ||
|
|
122
|
+
path.isAbsolute(pluginName)) {
|
|
123
|
+
return {
|
|
124
|
+
success: false,
|
|
125
|
+
error: `skillguard: install refused — invalid manifest name "${pluginName}" (manifest-integrity, force never lifts)`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// JD-006: import requires a non-empty, well-formed skills array. Each entry
|
|
129
|
+
// must carry a `name` and a `path` that resolves INSIDE sourceDir (normalized
|
|
130
|
+
// + realpath containment — "../../x" or an absolute path refuses; the gate
|
|
131
|
+
// never reads outside the staged clone, JD-003).
|
|
132
|
+
if (!Array.isArray(agentManifest.skills) ||
|
|
133
|
+
agentManifest.skills.length === 0) {
|
|
134
|
+
return {
|
|
135
|
+
success: false,
|
|
136
|
+
error: "skills.json must declare a non-empty skills array (every skill-shaped file must be declared)",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const sourceRootAbs = path.resolve(sourceDir);
|
|
140
|
+
const sourceRootReal = await fs.realpath(sourceRootAbs);
|
|
141
|
+
const declaredPaths = [];
|
|
142
|
+
for (const entry of agentManifest.skills) {
|
|
143
|
+
if (!entry || typeof entry.name !== "string" || !entry.name) {
|
|
144
|
+
return {
|
|
145
|
+
success: false,
|
|
146
|
+
error: "skills.json skills entry missing name",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (typeof entry.path !== "string" || !entry.path) {
|
|
150
|
+
return {
|
|
151
|
+
success: false,
|
|
152
|
+
error: `skills.json skills entry "${entry.name}" missing path`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const contained = await skillPathContained(sourceRootAbs, sourceRootReal, entry.path);
|
|
156
|
+
if (!contained.ok) {
|
|
157
|
+
return {
|
|
158
|
+
success: false,
|
|
159
|
+
error: `skills.json skills entry "${entry.name}" path escapes package root (${contained.reason}) — refusing`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
declaredPaths.push(entry.path);
|
|
163
|
+
}
|
|
92
164
|
if (dryRun) {
|
|
93
165
|
return { success: true, name: pluginName };
|
|
94
166
|
}
|
|
95
167
|
const destDir = path.join(PLUGINS_DIR, pluginName);
|
|
168
|
+
// ── SkillGuard runtime gate (D8, JD-001/JD-003/JD-006/JD-007) ──────────
|
|
169
|
+
// Runs BEFORE the existing-install remove and fs.copy: a refusal preserves
|
|
170
|
+
// an existing install and installs nothing. dryRun early-returns above, so
|
|
171
|
+
// no scan happens on dry-run. Scanner/eval errors deny unconditionally (D7).
|
|
172
|
+
try {
|
|
173
|
+
const coverage = await scanSkillsWithCoverage(sourceDir, declaredPaths);
|
|
174
|
+
// Manifest-integrity refusals — block-level, force NEVER lifts
|
|
175
|
+
// (JD-007: ANY symlink; JD-006: undeclared SKILL.md incl. node_modules).
|
|
176
|
+
// A walk with I/O errors cannot certify the copied footprint — refuse
|
|
177
|
+
// first, because the broken subtree may hide symlinks or undeclared
|
|
178
|
+
// files (JD-013).
|
|
179
|
+
if (coverage.errors.length > 0) {
|
|
180
|
+
return {
|
|
181
|
+
success: false,
|
|
182
|
+
error: `skillguard: install refused — ${coverage.errors.length} path(s) could not be read (walk incomplete; manifest-integrity, force never lifts):\n${coverage.errors.map((p) => ` ${p}`).join("\n")}`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (coverage.symlinks.length > 0) {
|
|
186
|
+
return {
|
|
187
|
+
success: false,
|
|
188
|
+
error: `skillguard: install refused — symlink(s) in tree (manifest-integrity, force never lifts):\n${coverage.symlinks.map((p) => ` ${p}`).join("\n")}`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (coverage.undeclared.length > 0) {
|
|
192
|
+
return {
|
|
193
|
+
success: false,
|
|
194
|
+
error: `skillguard: install refused — undeclared SKILL.md(s) in tree (every skill-shaped file must be declared; force never lifts):\n${coverage.undeclared.map((p) => ` ${p}`).join("\n")}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const gate = evaluateInstallGate(coverage.declared, { force });
|
|
198
|
+
if (!gate.allowed) {
|
|
199
|
+
const blocked = gate.rejected.filter((r) => r.verdict === "block").length;
|
|
200
|
+
const unscannable = gate.rejected.filter((r) => r.verdict === "unscannable").length;
|
|
201
|
+
return {
|
|
202
|
+
success: false,
|
|
203
|
+
// Lead line names the rejected count; the batch report renders
|
|
204
|
+
// the FULL declared set so the header/rows reflect every scanned
|
|
205
|
+
// skill (D6, JD-014).
|
|
206
|
+
error: `skillguard: install refused — ${gate.rejected.length} rejected (${blocked} blocked, ${unscannable} unscannable)\n${formatBatchReport(coverage.declared)}`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (scanError) {
|
|
211
|
+
const msg = scanError instanceof Error ? scanError.message : String(scanError);
|
|
212
|
+
return {
|
|
213
|
+
success: false,
|
|
214
|
+
error: `skillguard scan failed — ${msg}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
96
217
|
// Remove existing version if present
|
|
97
218
|
if (await fs.pathExists(destDir)) {
|
|
98
219
|
await fs.remove(destDir);
|
|
@@ -117,6 +238,39 @@ export async function importAgentSkillsPackage(sourceDir, options = {}) {
|
|
|
117
238
|
});
|
|
118
239
|
return { success: true, name: pluginName };
|
|
119
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* Verify a declared skill entry path stays inside the package root — both
|
|
243
|
+
* lexically (`../../x`, absolute paths) and by realpath, so an in-tree symlink
|
|
244
|
+
* cannot redirect the import read outside the staged clone (JD-003/JD-006).
|
|
245
|
+
* Realpath resolution is best-effort: a missing declared dir has no realpath
|
|
246
|
+
* yet, in which case lexical containment is the whole guard (the coverage walk
|
|
247
|
+
* will later report it as a missing/unscannable declared skill).
|
|
248
|
+
*/
|
|
249
|
+
async function skillPathContained(rootAbs, rootReal, entryPath) {
|
|
250
|
+
const entryAbs = path.resolve(rootAbs, entryPath);
|
|
251
|
+
const rel = path.relative(rootAbs, entryAbs);
|
|
252
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
reason: `path "${entryPath}" resolves outside the package root`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const entryReal = await fs.realpath(entryAbs);
|
|
260
|
+
const relReal = path.relative(rootReal, entryReal);
|
|
261
|
+
if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
reason: `path "${entryPath}" resolves outside the package root (realpath)`,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
// Declared dir does not exist yet — lexical containment stands; the
|
|
270
|
+
// coverage walk reports it as a missing declared skill later.
|
|
271
|
+
}
|
|
272
|
+
return { ok: true };
|
|
273
|
+
}
|
|
120
274
|
// ── Aggregation ──────────────────────────────────────────────────────────
|
|
121
275
|
/**
|
|
122
276
|
* Aggregate multiple installed plugins into a single Agent Skills spec manifest.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SkillScanResult } from "./skill-scanner.js";
|
|
1
2
|
import type { StackDetectionResult } from "./stack-detector.js";
|
|
2
3
|
export interface SkillInstallResult {
|
|
3
4
|
/** Skills that were successfully installed (copied) */
|
|
@@ -6,6 +7,8 @@ export interface SkillInstallResult {
|
|
|
6
7
|
skipped: string[];
|
|
7
8
|
/** Skills that were recommended but not found in the source */
|
|
8
9
|
notFound: string[];
|
|
10
|
+
/** Skills refused by the skillguard gate (block / unscannable w/o force) */
|
|
11
|
+
blocked: SkillScanResult[];
|
|
9
12
|
/** The full detection result for reporting */
|
|
10
13
|
detection: StackDetectionResult;
|
|
11
14
|
}
|
|
@@ -18,6 +21,8 @@ export interface AutoInstallOptions {
|
|
|
18
21
|
skillsTargetDir?: string;
|
|
19
22
|
/** If true, skip actually copying files */
|
|
20
23
|
dryRun?: boolean;
|
|
24
|
+
/** Bypass the gate for unscannable sources ONLY — block always refuses (D2) */
|
|
25
|
+
force?: boolean;
|
|
21
26
|
}
|
|
22
27
|
/**
|
|
23
28
|
* Scan a project, detect its stack, and install matching AI skills.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "fs-extra";
|
|
3
3
|
import { DEFAULT_SKILLS_DIR } from "../commands/skills/constants.js";
|
|
4
|
+
import { evaluateInstallGate } from "./skill-install-gate.js";
|
|
5
|
+
import { scanSkillFile } from "./skill-scanner.js";
|
|
4
6
|
import { detectProjectStack } from "./stack-detector.js";
|
|
5
7
|
// ── Core ───────────────────────────────────────────────────────────────────
|
|
6
8
|
/**
|
|
@@ -14,7 +16,7 @@ import { detectProjectStack } from "./stack-detector.js";
|
|
|
14
16
|
* source to target (useful for project-local skill installation).
|
|
15
17
|
*/
|
|
16
18
|
export async function autoInstallSkills(options) {
|
|
17
|
-
const { projectDir, skillsSourceDir = DEFAULT_SKILLS_DIR, skillsTargetDir = DEFAULT_SKILLS_DIR, dryRun = false, } = options;
|
|
19
|
+
const { projectDir, skillsSourceDir = DEFAULT_SKILLS_DIR, skillsTargetDir = DEFAULT_SKILLS_DIR, dryRun = false, force = false, } = options;
|
|
18
20
|
// 1. Detect project stack
|
|
19
21
|
const detection = await detectProjectStack(projectDir);
|
|
20
22
|
if (detection.recommendedSkills.length === 0) {
|
|
@@ -22,6 +24,7 @@ export async function autoInstallSkills(options) {
|
|
|
22
24
|
installed: [],
|
|
23
25
|
skipped: [],
|
|
24
26
|
notFound: [],
|
|
27
|
+
blocked: [],
|
|
25
28
|
detection,
|
|
26
29
|
};
|
|
27
30
|
}
|
|
@@ -29,6 +32,10 @@ export async function autoInstallSkills(options) {
|
|
|
29
32
|
const skipped = [];
|
|
30
33
|
const notFound = [];
|
|
31
34
|
const sameDir = path.resolve(skillsSourceDir) === path.resolve(skillsTargetDir);
|
|
35
|
+
// 2. Classify: notFound / sameDir-skipped / target-skipped / copyable.
|
|
36
|
+
// Predicates unchanged from pre-gate behavior; sameDir short-circuits
|
|
37
|
+
// BEFORE any scanning (D4).
|
|
38
|
+
const copyable = [];
|
|
32
39
|
for (const skillName of detection.recommendedSkills) {
|
|
33
40
|
const sourcePath = path.join(skillsSourceDir, skillName);
|
|
34
41
|
const targetPath = path.join(skillsTargetDir, skillName);
|
|
@@ -49,6 +56,32 @@ export async function autoInstallSkills(options) {
|
|
|
49
56
|
skipped.push(skillName);
|
|
50
57
|
continue;
|
|
51
58
|
}
|
|
59
|
+
copyable.push(skillName);
|
|
60
|
+
}
|
|
61
|
+
// 3. SkillGuard gate (D4, JD-009): scan-gate every copyable source via its
|
|
62
|
+
// folder-root SKILL.md. dryRun still scans (read-only) but copies nothing.
|
|
63
|
+
// A scan THROW rejects the whole function — nothing copied, matching the
|
|
64
|
+
// UI error path (AutoSkills.tsx:41-44).
|
|
65
|
+
const scans = [];
|
|
66
|
+
for (const skillName of copyable) {
|
|
67
|
+
const sourceSkillMd = path.join(skillsSourceDir, skillName, "SKILL.md");
|
|
68
|
+
scans.push(await scanSkillFile(sourceSkillMd));
|
|
69
|
+
}
|
|
70
|
+
const gate = evaluateInstallGate(scans, { force });
|
|
71
|
+
if (!gate.allowed) {
|
|
72
|
+
// Any block (or unscannable without force) ⇒ copy NOTHING.
|
|
73
|
+
return {
|
|
74
|
+
installed: [],
|
|
75
|
+
skipped,
|
|
76
|
+
notFound,
|
|
77
|
+
blocked: gate.rejected,
|
|
78
|
+
detection,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// 4. Copy every copyable skill — nothing was refused.
|
|
82
|
+
for (const skillName of copyable) {
|
|
83
|
+
const sourcePath = path.join(skillsSourceDir, skillName);
|
|
84
|
+
const targetPath = path.join(skillsTargetDir, skillName);
|
|
52
85
|
// Copy skill to target
|
|
53
86
|
if (!dryRun) {
|
|
54
87
|
await fs.ensureDir(targetPath);
|
|
@@ -59,7 +92,7 @@ export async function autoInstallSkills(options) {
|
|
|
59
92
|
}
|
|
60
93
|
installed.push(skillName);
|
|
61
94
|
}
|
|
62
|
-
return { installed, skipped, notFound, detection };
|
|
95
|
+
return { installed, skipped, notFound, blocked: [], detection };
|
|
63
96
|
}
|
|
64
97
|
/**
|
|
65
98
|
* Get a human-readable summary of auto-install results.
|
|
@@ -86,6 +119,11 @@ export function formatAutoInstallSummary(result) {
|
|
|
86
119
|
if (result.notFound.length > 0) {
|
|
87
120
|
lines.push(` Not found: ${result.notFound.join(", ")}`);
|
|
88
121
|
}
|
|
122
|
+
if (result.blocked.length > 0) {
|
|
123
|
+
lines.push(` Blocked: ${result.blocked
|
|
124
|
+
.map((b) => `${b.skillName} [${b.verdict.toUpperCase()}]`)
|
|
125
|
+
.join(", ")}`);
|
|
126
|
+
}
|
|
89
127
|
return lines.join("\n");
|
|
90
128
|
}
|
|
91
129
|
//# sourceMappingURL=auto-skill-install.js.map
|
package/dist/lib/context.d.ts
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
import type { InitOptions, StackContextEntry } from "../types/index.js";
|
|
2
2
|
export declare function buildIndexMd(projectName: string, stackCtx: StackContextEntry, ciProvider: string, memory: string): string;
|
|
3
3
|
export declare function buildSummaryMd(projectName: string, stack: string, ciProvider: string, memory: string, modules: string[], dependencies?: string[]): string;
|
|
4
|
+
/**
|
|
5
|
+
* Dependency detection outcome. `warnings` explains every manifest that could
|
|
6
|
+
* not be read or parsed, so a failure is distinguishable from "no dependencies"
|
|
7
|
+
* instead of being swallowed by a blanket catch.
|
|
8
|
+
*/
|
|
9
|
+
export interface DependencyDetection {
|
|
10
|
+
dependencies: string[];
|
|
11
|
+
warnings: string[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Detect top-level dependencies from project manifest files, reporting why a
|
|
15
|
+
* manifest was skipped. Returns up to 10 dependency names (key deps only).
|
|
16
|
+
*/
|
|
17
|
+
export declare function detectDependenciesDetailed(projectDir: string, stack: string): Promise<DependencyDetection>;
|
|
4
18
|
/**
|
|
5
19
|
* Detect top-level dependencies from project manifest files.
|
|
6
20
|
* Returns up to 10 dependency names (key deps only, not devDeps).
|
|
21
|
+
*
|
|
22
|
+
* List-returning convenience over `detectDependenciesDetailed`. It DISCARDS the
|
|
23
|
+
* read/parse warnings — an unreadable manifest is indistinguishable from an
|
|
24
|
+
* honest empty list through this function. Callers that must tell those two
|
|
25
|
+
* apart (like `refreshContextDir`) call `detectDependenciesDetailed` directly.
|
|
7
26
|
*/
|
|
8
27
|
export declare function detectDependencies(projectDir: string, stack: string): Promise<string[]>;
|
|
9
28
|
/**
|
|
@@ -23,5 +42,8 @@ export declare function refreshContextDir(projectDir: string): Promise<{
|
|
|
23
42
|
index: string;
|
|
24
43
|
summary: string;
|
|
25
44
|
updated: boolean;
|
|
45
|
+
/** Manifest read/parse warnings — surfaced so an unreadable manifest is not
|
|
46
|
+
* silently reported as a project with no dependencies. */
|
|
47
|
+
warnings: string[];
|
|
26
48
|
} | null>;
|
|
27
49
|
//# sourceMappingURL=context.d.ts.map
|
package/dist/lib/context.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "fs-extra";
|
|
3
3
|
import { STACK_CONTEXT_MAP } from "../constants.js";
|
|
4
|
+
import { describeSafeReadFailure, safeReadFile } from "./safe-read.js";
|
|
4
5
|
// =============================================================================
|
|
5
6
|
// Internal helpers
|
|
6
7
|
// =============================================================================
|
|
@@ -53,95 +54,135 @@ ${stack}-based project scaffolded with javi-forge.
|
|
|
53
54
|
// =============================================================================
|
|
54
55
|
// Dependency detection
|
|
55
56
|
// =============================================================================
|
|
57
|
+
const MAX_DEPS = 10;
|
|
56
58
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
+
* Byte ceiling for a dependency manifest. A package.json or go.mod past this is
|
|
60
|
+
* not a manifest we can learn anything useful from — it is generated noise.
|
|
59
61
|
*/
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
62
|
+
const MAX_MANIFEST_BYTES = 512 * 1024;
|
|
63
|
+
/** Read a manifest under a byte budget; returns null and a warning on failure. */
|
|
64
|
+
async function readManifest(manifestPath, warnings) {
|
|
65
|
+
const read = await safeReadFile(manifestPath, {
|
|
66
|
+
hardRejectOverBytes: MAX_MANIFEST_BYTES,
|
|
67
|
+
});
|
|
68
|
+
if (!read.ok) {
|
|
69
|
+
warnings.push(`${path.basename(manifestPath)}: ${describeSafeReadFailure(read)}`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (read.truncated) {
|
|
73
|
+
warnings.push(`${path.basename(manifestPath)}: truncated at ${read.bytesRead} of ${read.totalBytes} bytes`);
|
|
74
|
+
}
|
|
75
|
+
return read.content;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Detect top-level dependencies from project manifest files, reporting why a
|
|
79
|
+
* manifest was skipped. Returns up to 10 dependency names (key deps only).
|
|
80
|
+
*/
|
|
81
|
+
export async function detectDependenciesDetailed(projectDir, stack) {
|
|
82
|
+
const warnings = [];
|
|
83
|
+
switch (stack) {
|
|
84
|
+
case "node": {
|
|
85
|
+
const pkgPath = path.join(projectDir, "package.json");
|
|
86
|
+
if (!(await fs.pathExists(pkgPath)))
|
|
87
|
+
return { dependencies: [], warnings };
|
|
88
|
+
const content = await readManifest(pkgPath, warnings);
|
|
89
|
+
if (content === null)
|
|
90
|
+
return { dependencies: [], warnings };
|
|
91
|
+
let pkg;
|
|
92
|
+
try {
|
|
93
|
+
pkg = JSON.parse(content);
|
|
71
94
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
const reqPath = path.join(projectDir, "requirements.txt");
|
|
88
|
-
if (await fs.pathExists(reqPath)) {
|
|
89
|
-
const content = await fs.readFile(reqPath, "utf-8");
|
|
90
|
-
const deps = content
|
|
95
|
+
catch (err) {
|
|
96
|
+
warnings.push(`package.json: invalid JSON (${err instanceof Error ? err.message : String(err)})`);
|
|
97
|
+
return { dependencies: [], warnings };
|
|
98
|
+
}
|
|
99
|
+
const deps = Object.keys(pkg?.dependencies ?? {});
|
|
100
|
+
return { dependencies: deps.slice(0, MAX_DEPS), warnings };
|
|
101
|
+
}
|
|
102
|
+
case "python": {
|
|
103
|
+
const pyprojectPath = path.join(projectDir, "pyproject.toml");
|
|
104
|
+
if (await fs.pathExists(pyprojectPath)) {
|
|
105
|
+
const content = await readManifest(pyprojectPath, warnings);
|
|
106
|
+
const match = content?.match(/dependencies\s*=\s*\[([\s\S]*?)\]/);
|
|
107
|
+
if (match?.[1]) {
|
|
108
|
+
const deps = match[1]
|
|
91
109
|
.split("\n")
|
|
92
|
-
.map((l) => l.trim())
|
|
93
|
-
.filter((l) => l.length > 0 && !l.startsWith("#")
|
|
110
|
+
.map((l) => l.replace(/[",]/g, "").trim())
|
|
111
|
+
.filter((l) => l.length > 0 && !l.startsWith("#"))
|
|
94
112
|
.map((l) => l.split(/[>=<~!]/)[0].trim())
|
|
95
113
|
.filter(Boolean);
|
|
96
|
-
return deps.slice(0, MAX_DEPS);
|
|
114
|
+
return { dependencies: deps.slice(0, MAX_DEPS), warnings };
|
|
97
115
|
}
|
|
98
|
-
return [];
|
|
99
116
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const parts = l.split(/\s+/);
|
|
113
|
-
const mod = parts[0] ?? "";
|
|
114
|
-
return mod.split("/").pop() ?? mod;
|
|
115
|
-
})
|
|
116
|
-
.filter(Boolean);
|
|
117
|
-
return deps.slice(0, MAX_DEPS);
|
|
118
|
-
}
|
|
119
|
-
return [];
|
|
117
|
+
const reqPath = path.join(projectDir, "requirements.txt");
|
|
118
|
+
if (await fs.pathExists(reqPath)) {
|
|
119
|
+
const content = await readManifest(reqPath, warnings);
|
|
120
|
+
if (content === null)
|
|
121
|
+
return { dependencies: [], warnings };
|
|
122
|
+
const deps = content
|
|
123
|
+
.split("\n")
|
|
124
|
+
.map((l) => l.trim())
|
|
125
|
+
.filter((l) => l.length > 0 && !l.startsWith("#") && !l.startsWith("-"))
|
|
126
|
+
.map((l) => l.split(/[>=<~!]/)[0].trim())
|
|
127
|
+
.filter(Boolean);
|
|
128
|
+
return { dependencies: deps.slice(0, MAX_DEPS), warnings };
|
|
120
129
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
130
|
+
return { dependencies: [], warnings };
|
|
131
|
+
}
|
|
132
|
+
case "go": {
|
|
133
|
+
const goModPath = path.join(projectDir, "go.mod");
|
|
134
|
+
if (!(await fs.pathExists(goModPath)))
|
|
135
|
+
return { dependencies: [], warnings };
|
|
136
|
+
const content = await readManifest(goModPath, warnings);
|
|
137
|
+
const requireBlock = content?.match(/require\s*\(([\s\S]*?)\)/);
|
|
138
|
+
if (requireBlock?.[1]) {
|
|
139
|
+
const deps = requireBlock[1]
|
|
140
|
+
.split("\n")
|
|
141
|
+
.map((l) => l.trim())
|
|
142
|
+
.filter((l) => l.length > 0 && !l.startsWith("//"))
|
|
143
|
+
.map((l) => {
|
|
144
|
+
const parts = l.split(/\s+/);
|
|
145
|
+
const mod = parts[0] ?? "";
|
|
146
|
+
return mod.split("/").pop() ?? mod;
|
|
147
|
+
})
|
|
148
|
+
.filter(Boolean);
|
|
149
|
+
return { dependencies: deps.slice(0, MAX_DEPS), warnings };
|
|
137
150
|
}
|
|
138
|
-
|
|
139
|
-
return [];
|
|
151
|
+
return { dependencies: [], warnings };
|
|
140
152
|
}
|
|
153
|
+
case "rust": {
|
|
154
|
+
const cargoPath = path.join(projectDir, "Cargo.toml");
|
|
155
|
+
if (!(await fs.pathExists(cargoPath)))
|
|
156
|
+
return { dependencies: [], warnings };
|
|
157
|
+
const content = await readManifest(cargoPath, warnings);
|
|
158
|
+
const depsSection = content?.match(/\[dependencies\]([\s\S]*?)(?=\n\[|$)/);
|
|
159
|
+
if (depsSection?.[1]) {
|
|
160
|
+
const deps = depsSection[1]
|
|
161
|
+
.split("\n")
|
|
162
|
+
.map((l) => l.trim())
|
|
163
|
+
.filter((l) => l.length > 0 && !l.startsWith("#"))
|
|
164
|
+
.map((l) => l.split(/\s*=/)[0].trim())
|
|
165
|
+
.filter(Boolean);
|
|
166
|
+
return { dependencies: deps.slice(0, MAX_DEPS), warnings };
|
|
167
|
+
}
|
|
168
|
+
return { dependencies: [], warnings };
|
|
169
|
+
}
|
|
170
|
+
default:
|
|
171
|
+
return { dependencies: [], warnings };
|
|
141
172
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Detect top-level dependencies from project manifest files.
|
|
176
|
+
* Returns up to 10 dependency names (key deps only, not devDeps).
|
|
177
|
+
*
|
|
178
|
+
* List-returning convenience over `detectDependenciesDetailed`. It DISCARDS the
|
|
179
|
+
* read/parse warnings — an unreadable manifest is indistinguishable from an
|
|
180
|
+
* honest empty list through this function. Callers that must tell those two
|
|
181
|
+
* apart (like `refreshContextDir`) call `detectDependenciesDetailed` directly.
|
|
182
|
+
*/
|
|
183
|
+
export async function detectDependencies(projectDir, stack) {
|
|
184
|
+
const { dependencies } = await detectDependenciesDetailed(projectDir, stack);
|
|
185
|
+
return dependencies;
|
|
145
186
|
}
|
|
146
187
|
// =============================================================================
|
|
147
188
|
// Public API
|
|
@@ -190,7 +231,7 @@ export async function refreshContextDir(projectDir) {
|
|
|
190
231
|
return null;
|
|
191
232
|
}
|
|
192
233
|
const stackCtx = getStackContext(manifest.stack);
|
|
193
|
-
const dependencies = await
|
|
234
|
+
const { dependencies, warnings } = await detectDependenciesDetailed(projectDir, manifest.stack);
|
|
194
235
|
const index = buildIndexMd(manifest.projectName, stackCtx, manifest.ciProvider, manifest.memory);
|
|
195
236
|
const summary = buildSummaryMd(manifest.projectName, manifest.stack, manifest.ciProvider, manifest.memory, manifest.modules, dependencies);
|
|
196
237
|
// Write updated files
|
|
@@ -199,6 +240,6 @@ export async function refreshContextDir(projectDir) {
|
|
|
199
240
|
// Update manifest timestamp
|
|
200
241
|
manifest.updatedAt = new Date().toISOString();
|
|
201
242
|
await fs.writeJson(manifestPath, manifest, { spaces: 2 });
|
|
202
|
-
return { index, summary, updated: true };
|
|
243
|
+
return { index, summary, updated: true, warnings };
|
|
203
244
|
}
|
|
204
245
|
//# sourceMappingURL=context.js.map
|
package/dist/lib/plugin.d.ts
CHANGED