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
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ see [architecture.md](architecture.md#information-flow) for that flow.
| `buildSpaceGraph(nodes, hierarchy?)` | Build a navigable `SpaceGraph` directly from an array of `SpaceNode`s and a `Hierarchy` definition. |
| `exportSpaceGraph(nodes, metadata)` | Turn nodes and the schema's hierarchies into plain JSON for another process. See [Space graph format](#space-graph-format). |
| `spaceGraphFrom(data, options?)` | Build a `SpaceGraph` from that JSON. `options.hierarchy` names the hierarchy that structures it (default: the main hierarchy). |
| `validateSpace(context, options?)` | Run full validation (schema, references, rules, duplicates) over a space. |
| `validateSpace(context, options?)` | Run full validation (schema, references, rules, duplicates) over a space. Semantic rules run only when `semantic` is passed; it takes the same values as `sctx validate --semantic`, with the provider coming from config and the environment. |
| `validateFile(filePath, config)` | Validate a single file within its space. |
| `listSchemas(options?)` | List the schemas available to a config: bundled, plugin-contributed and configured. |
| `formatWikilink(target, alias?)` | Format a target into an Obsidian wikilink (`[[target]]` or `[[target|alias]]`). |
Expand Down
1 change: 1 addition & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type { Config, SpaceConfig } from './config';
export { loadConfig, resolveConfigPath, resolveSchema, resolveSchemaPath, setConfigPath } from './config';
export { addSpace } from './config/write';
export { LOCAL_CONFIG_FILE, PLUGIN_PREFIX } from './constants';
export { SctxError, type SctxErrorCode } from './errors';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds SctxError and SctxErrorCode to the public API, which breaks a rule in CLAUDE.md: "Do not add exports to src/api.ts ... without the maintainer's explicit agreement on that specific surface... If a change seems to need a new export, stop and propose it, with the consumer that needs it." The PR description also says "api.ts does not grow", which this line contradicts.

SctxErrorCode is a separate export from SctxError, so agreeing to one doesn't cover the other. Remove this line, or get the maintainer's agreement on it first. Library callers can still detect the new error by checking err.code === 'E_MODEL_PROVIDER_INVALID'.

export { assembleSpaceGraph, loadSpaceGraph } from './load-space-graph';
export type {
ParseHook,
Expand Down
18 changes: 8 additions & 10 deletions src/commands/validate-file.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { loadConfig } from '../config';
import { judgeForRun } from '../judge/factory';
import { parseRuleFilter } from '../schema/rule-filter';
import { createSpaceContext } from '../space-context';
import type { FileValidationIssue, FileValidationResult, ValidateFileOutput } from '../types';
import { fileNotInSpace, resolveFileSpace, validateResolvedFile } from '../validate';
import { fileNotInSpace, resolveFileSpace, type ValidateFileOptions, validateResolvedFile } from '../validate';

export { validateFile } from '../validate';

Expand All @@ -14,18 +12,18 @@ export { validateFile } from '../validate';
*/
export async function validateFileCommand(
filePath: string,
options: { json?: boolean; includeGated?: boolean; semantic?: boolean | string; ruleFilter?: string[] } = {},
options: { json?: boolean } & Omit<ValidateFileOptions, 'configDir'> = {},
): Promise<number> {
const config = loadConfig();
const resolved = resolveFileSpace(filePath, config);
let result: ValidateFileOutput;
if (resolved) {
const judge = judgeForRun(config, resolved.space, options.semantic);
result = await validateResolvedFile(filePath, resolved.label, createSpaceContext(resolved.space.name, config), {
includeGated: options.includeGated,
judge,
ruleFilter: options.ruleFilter && parseRuleFilter(options.ruleFilter),
});
result = await validateResolvedFile(
filePath,
resolved.label,
createSpaceContext(resolved.space.name, config),
options,
);
} else {
result = fileNotInSpace(filePath);
}
Expand Down
19 changes: 3 additions & 16 deletions src/commands/validate.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,16 @@
import { dirname } from 'node:path';
import chokidar from 'chokidar';
import { getConfigSourceFiles } from '../config';
import { judgeForRun } from '../judge/factory';
import { resolveSpacePluginSchemas } from '../plugins/util';
import { parseRuleFilter } from '../schema/rule-filter';
import { bundledSchemasDir } from '../schema/schema';
import type { FileValidationIssue, RuleSeverity, SpaceContext } from '../types';
import { formatSource, ruleViolationMessage, validateSpace } from '../validate';
import { formatSource, ruleViolationMessage, type ValidateSpaceOptions, validateSpace } from '../validate';

export async function validate(
context: SpaceContext,
options: {
json?: boolean;
depth?: number;
includeGated?: boolean;
semantic?: boolean | string;
ruleFilter?: string[];
} = {},
options: { json?: boolean } & ValidateSpaceOptions = {},
): Promise<number> {
const result = await validateSpace(context, {
depth: options.depth,
includeGated: options.includeGated,
judge: judgeForRun(context.config, context.space, options.semantic),
ruleFilter: options.ruleFilter && parseRuleFilter(options.ruleFilter),
});
const result = await validateSpace(context, options);
const ruleCounts: Record<RuleSeverity, number> = { error: 0, warning: 0, info: 0 };
for (const v of result.ruleViolations) ruleCounts[v.severity]++;

Expand Down
3 changes: 2 additions & 1 deletion src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export type SctxErrorCode =
| 'E_SCHEMA_INVALID'
| 'E_SPACE_EXISTS'
| 'E_TARGET_REQUIRED'
| 'E_CONFIG_NEEDS_MANUAL_EDIT';
| 'E_CONFIG_NEEDS_MANUAL_EDIT'
| 'E_MODEL_PROVIDER_INVALID';

export class SctxError extends Error {
constructor(
Expand Down
42 changes: 13 additions & 29 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import { validateFileCommand } from './commands/validate-file';
import { loadConfig, setConfigPath } from './config';
import { CLI_NAME } from './constants';
import { SctxError } from './errors';
import { JudgeConfigError } from './judge/factory';
import { createSpaceContext, SpaceNotFoundError } from './space-context';
import type { SpaceContext } from './types';

Expand All @@ -39,17 +38,6 @@ export function buildSpaceContext(spaceName: string): SpaceContext {
}
}

/** Run a validate command, reporting a misconfigured model provider as a plain error rather than a stack trace. */
async function reportJudgeConfigErrors(run: () => Promise<number>): Promise<number> {
try {
return await run();
} catch (err) {
if (!(err instanceof JudgeConfigError)) throw err;
console.error(`Error: ${err.message}`);
return 1;
}
}

const require = createRequire(import.meta.url);
const packageJson = require('../package.json');

Expand Down Expand Up @@ -81,14 +69,12 @@ program
)
.option('--semantic [provider]', 'Evaluate semantic rules, with the configured model provider or the one named')
.action(async (filePath, options) => {
process.exitCode = await reportJudgeConfigErrors(() =>
validateFileCommand(filePath, {
json: options.json,
includeGated: options.includeGated,
semantic: options.semantic,
ruleFilter: options.ruleFilter,
}),
);
process.exitCode = await validateFileCommand(filePath, {
json: options.json,
includeGated: options.includeGated,
semantic: options.semantic,
ruleFilter: options.ruleFilter,
});
});

program
Expand Down Expand Up @@ -118,15 +104,13 @@ program
await watchValidate(context);
} else {
// exitCode rather than process.exit(): exiting straight away can cut off large piped output
process.exitCode = await reportJudgeConfigErrors(() =>
validate(context, {
json: options.json,
depth: options.depth,
includeGated: options.includeGated,
semantic: options.semantic,
ruleFilter: options.ruleFilter,
}),
);
process.exitCode = await validate(context, {
json: options.json,
depth: options.depth,
includeGated: options.includeGated,
semantic: options.semantic,
ruleFilter: options.ruleFilter,
});
}
});

Expand Down
19 changes: 12 additions & 7 deletions src/judge/factory.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SctxError } from '../errors';
import type { AnthropicJudgeProviderOptions } from './provider/anthropic';
import { AnthropicJudgeProvider, RECOMMENDED_ANTHROPIC_MODEL } from './provider/anthropic';
import { FakeJudgeProvider } from './provider/fake';
Expand Down Expand Up @@ -83,7 +84,6 @@ const DEFAULT_KEY_ENV: Record<string, string> = {
} as const;

/** Semantic evaluation is misconfigured (no provider, unknown provider, missing key). Reported as a plain CLI error. */
export class JudgeConfigError extends Error {}

/**
* Hydrate a provider definition into concrete values, in one place. Per field: the explicit value
Expand All @@ -92,7 +92,8 @@ export class JudgeConfigError extends Error {}
*/
export function hydrateProvider(name: string, def: ModelProviderConfig, env: Env = process.env): HydratedProvider {
if (!(MODEL_PROVIDER_APIS as readonly string[]).includes(def.api)) {
throw new JudgeConfigError(
throw new SctxError(
'E_MODEL_PROVIDER_INVALID',
`Model provider "${name}" has unknown api "${def.api}". Use one of: ${MODEL_PROVIDER_APIS.join(', ')}.`,
);
}
Expand All @@ -101,7 +102,8 @@ export function hydrateProvider(name: string, def: ModelProviderConfig, env: Env
if (!envName) return undefined;
const envValue = env[envName];
if (envValue !== undefined && envValue.trim() === '') {
throw new JudgeConfigError(
throw new SctxError(
'E_MODEL_PROVIDER_INVALID',
`Model provider "${name}" has ${envName} set but empty; set a value or unset the variable.`,
);
}
Expand All @@ -128,7 +130,8 @@ export function createModelProvider(
// OpenAI-compatible endpoints (Ollama, vLLM, ...) may need no key; the others must resolve one.
// A key never borrows another provider's variable: only the named apiKeyEnv (or literal apiKey) counts.
if (!h.apiKey && h.api !== 'openai-completions' && h.api !== 'fake') {
throw new JudgeConfigError(
throw new SctxError(
'E_MODEL_PROVIDER_INVALID',
`Model provider "${name}" (${h.api}) has no API key. Set ${h.keyEnv} in the environment, or "apiKey" / "apiKeyEnv" on the provider.`,
);
}
Expand Down Expand Up @@ -163,7 +166,7 @@ export function judgeForRun(
/**
* The provider for a run: the one named by `name` (the `--semantic <provider>` override), else the space's
* `semantic.provider`, else the top-level one. The name resolves against `modelProviders` first, then
* the bundled providers. Throws {@link JudgeConfigError} when it cannot be built.
* the bundled providers. Throws {@link SctxError} `E_MODEL_PROVIDER_INVALID` when it cannot be built.
*/
export function createJudge(
config: { modelProviders?: Record<string, ModelProviderConfig>; semantic?: { provider?: string } },
Expand All @@ -173,15 +176,17 @@ export function createJudge(
): SemanticJudgeProvider {
const chosen = name ?? space.semantic?.provider ?? config.semantic?.provider;
if (!chosen) {
throw new JudgeConfigError(
throw new SctxError(
'E_MODEL_PROVIDER_INVALID',
'--semantic needs a model provider. Set "semantic.provider" in config.json, or pass --semantic <provider>.',
);
}
const def = config.modelProviders?.[chosen] ?? BUNDLED_PROVIDERS[chosen];
if (!def) {
const known = [...Object.keys(BUNDLED_PROVIDERS), ...Object.keys(config.modelProviders ?? {})];

throw new JudgeConfigError(
throw new SctxError(
'E_MODEL_PROVIDER_INVALID',
`Model provider "${chosen}" is not configured.${known.length ? ` Known providers: ${known.join(', ')}.` : ''}`,
);
}
Expand Down
21 changes: 0 additions & 21 deletions src/judge/index.ts

This file was deleted.

Loading
Loading