Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 49 additions & 24 deletions cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,9 +347,10 @@ async function loadConfig(args: ParsedArgs): Promise<CliOptions | { error: strin
if (!args.configPath) return args;
try {
const raw = await readFile(args.configPath, "utf8");
const config = JSON.parse(raw) as CliConfig;
const validationError = validateConfig(config);
const candidate: unknown = JSON.parse(raw);
const validationError = validateConfig(candidate);
if (validationError) return { error: validationError };
const config = candidate as CliConfig;

return {
...args,
Expand Down Expand Up @@ -684,40 +685,64 @@ function getExitCode(result: CliScanResult, failOn: FailOn): number {
return result.issues.length > 0 ? 1 : 0;
}

function validateConfig(config: CliConfig): string | null {
if (config.scanners) {
const validation = validateScanners(config.scanners);
if (validation) return validation;
function validateConfig(value: unknown): string | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return "Config must be a JSON object";
}
if (config.severity) {
const invalid = config.severity.find((item) => !isSeverity(item));
if (invalid) return `Unknown severity: ${String(invalid)}`;
const config = value as Record<string, unknown>;
const arrayKeys = [
"scanners", "severity", "include", "exclude", "ignoredFolders",
"ignoredLargeMarkdownFrontmatterKeys", "ignoredLargeMarkdownPathPatterns",
"watchedTags", "ignoredProperties",
];
for (const key of arrayKeys) {
const field = config[key];
if (field !== undefined &&
(!Array.isArray(field) || field.some((item) => typeof item !== "string"))) {
return `${key} must be an array of strings`;
}
}
const numberKeys = [
"largeMarkdownBytes", "largeAttachmentBytes", "duplicateHashMaxBytes",
"lowUsageTagThreshold", "emptyNoteWordThreshold",
];
for (const key of numberKeys) {
const field = config[key];
if (field !== undefined && (typeof field !== "number" ||
!Number.isFinite(field) || !Number.isInteger(field) || field < 0)) {
return `${key} must be a finite non-negative integer`;
}
}
if (config.baselinePath !== undefined && typeof config.baselinePath !== "string") {
return "baselinePath must be a string";
}
if (config.scanners !== undefined) {
const error = validateScanners(config.scanners as string[]);
if (error) return error;
}
if (config.severity !== undefined) {
for (const severity of config.severity as string[]) {
if (!isSeverity(severity)) return `Unknown severity: ${severity}`;
}
}
if (config.failOn && !isFailOn(config.failOn)) {
if (config.failOn !== undefined && !isFailOn(config.failOn)) {
return `Unsupported failOn value: ${String(config.failOn)}`;
}
if (
config.ignoreUnresolvedNoteLinks !== undefined &&
typeof config.ignoreUnresolvedNoteLinks !== "boolean"
) {
if (config.ignoreUnresolvedNoteLinks !== undefined &&
typeof config.ignoreUnresolvedNoteLinks !== "boolean") {
return "ignoreUnresolvedNoteLinks must be a boolean";
}
if (config.ignoredFoldersByScanner !== undefined) {
if (
typeof config.ignoredFoldersByScanner !== "object" ||
config.ignoredFoldersByScanner === null ||
Array.isArray(config.ignoredFoldersByScanner)
) {
const foldersByScanner = config.ignoredFoldersByScanner;
if (typeof foldersByScanner !== "object" || foldersByScanner === null ||
Array.isArray(foldersByScanner)) {
return "ignoredFoldersByScanner must be an object of scanner IDs to folder arrays";
}
for (const [scannerId, folders] of Object.entries(config.ignoredFoldersByScanner)) {
for (const [scannerId, folders] of Object.entries(foldersByScanner)) {
if (!SCANNER_IDS.includes(scannerId as ScannerId)) {
return `Unknown scanner in ignoredFoldersByScanner: ${scannerId}`;
}
if (
!Array.isArray(folders) ||
folders.some((folder) => typeof folder !== "string")
) {
if (!Array.isArray(folders) || folders.some((folder) => typeof folder !== "string")) {
return `ignoredFoldersByScanner.${scannerId} must be an array of folder paths`;
}
}
Expand Down
6 changes: 6 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ vinspect . --baseline .vault-inspector-baseline.json --fail-on new

## Configuration

Configuration must be a JSON object. Thresholds and the duplicate hash cap must
be finite non-negative integers; numeric strings are not accepted. List options
must be arrays of strings. Invalid configuration exits with code 2 before the
vault is scanned, even when a command-line option would override that field.
Unknown configuration keys are ignored.

Config files are JSON and use the same option names:

```json
Expand Down
47 changes: 47 additions & 0 deletions src/tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,53 @@ describe("runCli", () => {
});
});

it.each([
["[]", "Config must be a JSON object"],
["null", "Config must be a JSON object"],
["true", "Config must be a JSON object"],
['"config"', "Config must be a JSON object"],
['{"largeMarkdownBytes":"garbage"}', "largeMarkdownBytes must be a finite non-negative integer"],
['{"largeMarkdownBytes":"10"}', "largeMarkdownBytes must be a finite non-negative integer"],
['{"largeMarkdownBytes":1e999}', "largeMarkdownBytes must be a finite non-negative integer"],
['{"largeAttachmentBytes":-1}', "largeAttachmentBytes must be a finite non-negative integer"],
['{"duplicateHashMaxBytes":null}', "duplicateHashMaxBytes must be a finite non-negative integer"],
['{"lowUsageTagThreshold":1.5}', "lowUsageTagThreshold must be a finite non-negative integer"],
['{"emptyNoteWordThreshold":false}', "emptyNoteWordThreshold must be a finite non-negative integer"],
['{"scanners":"large-files"}', "scanners must be an array of strings"],
['{"severity":[0]}', "severity must be an array of strings"],
['{"include":null}', "include must be an array of strings"],
['{"exclude":[1]}', "exclude must be an array of strings"],
['{"watchedTags":{}}', "watchedTags must be an array of strings"],
['{"baselinePath":false}', "baselinePath must be a string"],
['{"failOn":""}', "Unsupported failOn value: "],
])("rejects invalid config %s before scanning", async (raw, message) => {
const dir = await mkdtemp(join(tmpdir(), "vi-invalid-config-"));
try {
const config = join(dir, "config.json");
await writeFile(config, raw, "utf8");
// This vault deliberately does not exist. Config validation must win.
const result = await runCli([join(dir, "missing-vault"), "--config", config]);
expect(result).toEqual({ exitCode: 2, stdout: "", stderr: `${message}\n` });
} finally {
await rm(dir, { recursive: true, force: true });
}
});

it.each([0, 10])("accepts integer config threshold %s without losing findings", async (threshold) => {
await withVault({ "Note.md": "x".repeat(30) }, async (vaultPath) => {
const config = join(vaultPath, "config.json");
await writeFile(config, JSON.stringify({
scanners: ["large-files"], largeMarkdownBytes: threshold, failOn: "none",
}), "utf8");
const result = await runCli([vaultPath, "--config", config, "--format", "json"]);
expect(result.exitCode).toBe(0);
expect(result.stderr).toBe("");
expect(JSON.parse(result.stdout).issues).toEqual(expect.arrayContaining([
expect.objectContaining({ scannerId: "large-files", primaryPath: "Note.md" }),
]));
});
});

it("preserves valid block links in the actual CLI bundle", async () => {
await withVault({
"Source.md": "[[Target#^KNOWN-ID|Alias]]\n![[Target#^Known-id]]\n[[Target#^missing]]\n",
Expand Down