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
119 changes: 62 additions & 57 deletions src/cli/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,13 +191,21 @@ export function commandNeedsExtensions(args: string[]): boolean {
return !SKIP_EXTENSION_COMMANDS.has(commandInfo.command);
}

/** A deferred warning message to emit after logging is initialized. */
export interface DeferredWarning {
kind: "model" | "vault" | "driver" | "datastore" | "report";
file: string;
error: string;
}

/**
* Load user models from configured directory.
*/
async function loadUserModels(
repoDir: string,
marker: RepoMarkerData | null,
denoRuntime: EmbeddedDenoRuntime,
deferredWarnings: DeferredWarning[],
): Promise<void> {
try {
const modelsDir = resolveModelsDir(marker);
Expand All @@ -209,18 +217,16 @@ async function loadUserModels(
const loader = new UserModelLoader(denoRuntime, repoDir);
const result = await loader.loadModels(absoluteModelsDir);

// Log extension successes at debug level
for (const file of result.extended) {
logger.debug`Extended model type from ${file}`;
}

// Log failures as warnings (don't block CLI startup)
// Collect failures for deferred logging (logging not yet initialized)
for (const failure of result.failed) {
logger.warn`Failed to load user model ${failure.file}: ${failure.error}`;
deferredWarnings.push({
kind: "model",
file: failure.file,
error: failure.error,
});
}
} catch (error) {
// Not in a swamp repo or other error - log at debug level for troubleshooting
logger.debug`Skipping user models: ${error}`;
} catch {
// Not in a swamp repo or models dir doesn't exist — not an error
}
}

Expand All @@ -231,6 +237,7 @@ async function loadUserVaults(
repoDir: string,
marker: RepoMarkerData | null,
denoRuntime: EmbeddedDenoRuntime,
deferredWarnings: DeferredWarning[],
): Promise<void> {
try {
const vaultsDir = resolveVaultsDir(marker);
Expand All @@ -241,25 +248,23 @@ async function loadUserVaults(
const loader = new UserVaultLoader(denoRuntime, repoDir);
const result = await loader.loadVaults(absoluteVaultsDir);

// Log successes at debug level
for (const file of result.loaded) {
logger.debug`Loaded user vault type from ${file}`;
}

// Log failures as warnings (don't block CLI startup)
for (const failure of result.failed) {
logger.warn`Failed to load user vault ${failure.file}: ${failure.error}`;
deferredWarnings.push({
kind: "vault",
file: failure.file,
error: failure.error,
});
}
} catch (error) {
// Not in a swamp repo or other error - log at debug level for troubleshooting
logger.debug`Skipping user vaults: ${error}`;
} catch {
// Not in a swamp repo or vaults dir doesn't exist — not an error
}
}

async function loadUserDrivers(
repoDir: string,
marker: RepoMarkerData | null,
denoRuntime: EmbeddedDenoRuntime,
deferredWarnings: DeferredWarning[],
): Promise<void> {
try {
const driversDir = resolveDriversDir(marker);
Expand All @@ -270,25 +275,23 @@ async function loadUserDrivers(
const loader = new UserDriverLoader(denoRuntime, repoDir);
const result = await loader.loadDrivers(absoluteDriversDir);

// Log successes at debug level
for (const file of result.loaded) {
logger.debug`Loaded user driver type from ${file}`;
}

// Log failures as warnings (don't block CLI startup)
for (const failure of result.failed) {
logger.warn`Failed to load user driver ${failure.file}: ${failure.error}`;
deferredWarnings.push({
kind: "driver",
file: failure.file,
error: failure.error,
});
}
} catch (error) {
// Not in a swamp repo or other error - log at debug level for troubleshooting
logger.debug`Skipping user drivers: ${error}`;
} catch {
// Not in a swamp repo or drivers dir doesn't exist — not an error
}
}

async function loadUserDatastores(
repoDir: string,
marker: RepoMarkerData | null,
denoRuntime: EmbeddedDenoRuntime,
deferredWarnings: DeferredWarning[],
): Promise<void> {
try {
const datastoresDir = resolveDatastoresDir(marker);
Expand All @@ -299,26 +302,23 @@ async function loadUserDatastores(
const loader = new UserDatastoreLoader(denoRuntime, repoDir);
const result = await loader.loadDatastores(absoluteDatastoresDir);

// Log successes at debug level
for (const file of result.loaded) {
logger.debug`Loaded user datastore type from ${file}`;
}

// Log failures as warnings (don't block CLI startup)
for (const failure of result.failed) {
logger
.warn`Failed to load user datastore ${failure.file}: ${failure.error}`;
deferredWarnings.push({
kind: "datastore",
file: failure.file,
error: failure.error,
});
}
} catch (error) {
// Not in a swamp repo or other error - log at debug level for troubleshooting
logger.debug`Skipping user datastores: ${error}`;
} catch {
// Not in a swamp repo or datastores dir doesn't exist — not an error
}
}

async function loadUserReports(
repoDir: string,
marker: RepoMarkerData | null,
denoRuntime: EmbeddedDenoRuntime,
deferredWarnings: DeferredWarning[],
): Promise<void> {
try {
const reportsDir = resolveReportsDir(marker);
Expand All @@ -329,18 +329,15 @@ async function loadUserReports(
const loader = new UserReportLoader(denoRuntime, repoDir);
const result = await loader.loadReports(absoluteReportsDir);

// Log successes at debug level
for (const file of result.loaded) {
logger.debug`Loaded user report from ${file}`;
}

// Log failures as warnings (don't block CLI startup)
for (const failure of result.failed) {
logger.warn`Failed to load user report ${failure.file}: ${failure.error}`;
deferredWarnings.push({
kind: "report",
file: failure.file,
error: failure.error,
});
}
} catch (error) {
// Not in a swamp repo or other error - log at debug level for troubleshooting
logger.debug`Skipping user reports: ${error}`;
} catch {
// Not in a swamp repo or reports dir doesn't exist — not an error
}
}

Expand Down Expand Up @@ -496,15 +493,17 @@ export async function runCli(args: string[]): Promise<void> {
// Not in a swamp repo - marker stays null
}

// Load user extensions in parallel (skip for commands that don't need them)
// Load user extensions in parallel (skip for commands that don't need them).
// Collect warnings because logging is not yet initialized at this point.
const deferredWarnings: DeferredWarning[] = [];
if (commandNeedsExtensions(args)) {
const denoRuntime = new EmbeddedDenoRuntime();
await Promise.all([
loadUserModels(repoDir, marker, denoRuntime),
loadUserVaults(repoDir, marker, denoRuntime),
loadUserDrivers(repoDir, marker, denoRuntime),
loadUserDatastores(repoDir, marker, denoRuntime),
loadUserReports(repoDir, marker, denoRuntime),
loadUserModels(repoDir, marker, denoRuntime, deferredWarnings),
loadUserVaults(repoDir, marker, denoRuntime, deferredWarnings),
loadUserDrivers(repoDir, marker, denoRuntime, deferredWarnings),
loadUserDatastores(repoDir, marker, denoRuntime, deferredWarnings),
loadUserReports(repoDir, marker, denoRuntime, deferredWarnings),
]);
}

Expand Down Expand Up @@ -602,6 +601,12 @@ export async function runCli(args: string[]): Promise<void> {
jsonMode: options.json ?? false,
noColor,
});

// Emit deferred warnings now that logging is initialized
for (const warning of deferredWarnings) {
logger
.warn`Failed to load user ${warning.kind} ${warning.file}: ${warning.error}`;
}
})
.error(unknownCommandErrorHandler)
.action(function () {
Expand Down
91 changes: 86 additions & 5 deletions src/domain/extensions/extension_content_extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,19 +325,45 @@ function extractVaultFromSource(

/**
* Extracts method definitions from the source.
* Matches the `methods: { name: { description: "..." } }` pattern.
* Handles three patterns:
* 1. Inline: `methods: { name: { description: "..." } }`
* 2. Shorthand property: `methods,` (references a variable named `methods`)
* 3. Variable reference: `methods: someVar` (references a named variable)
*/
function extractMethods(content: string): ExtractedMethod[] {
// Try inline methods block first: methods: { ... }
const inlineMethods = extractMethodsFromBlock(content, content);
if (inlineMethods.length > 0) return inlineMethods;

// Try variable reference: methods: someVar or shorthand methods,
const refName = resolveMethodsReference(content);
if (!refName) return [];

// Look up the referenced variable in the same file
const varBlock = findVariableObjectBody(content, refName);
if (varBlock) {
return extractMethodsFromBlock(varBlock, content);
}

return [];
}

/**
* Extracts methods from an inline `methods: { ... }` block within the content.
*/
function extractMethodsFromBlock(
searchContent: string,
fullContent: string,
): ExtractedMethod[] {
const methods: ExtractedMethod[] = [];

// Find the methods block
const methodsBlockMatch = content.match(/methods:\s*\{/);
const methodsBlockMatch = searchContent.match(/methods:\s*\{/);
if (!methodsBlockMatch || methodsBlockMatch.index === undefined) {
return methods;
}

const methodsStart = methodsBlockMatch.index + methodsBlockMatch[0].length;
const methodsBlock = extractBalancedBraces(content, methodsStart);
const methodsBlock = extractBalancedBraces(searchContent, methodsStart);
if (!methodsBlock) return methods;

// Match individual method entries: methodName: { description: "..." }
Expand All @@ -351,7 +377,7 @@ function extractMethods(content: string): ExtractedMethod[] {
// Try to extract arguments for this method
const methodEntry = extractMethodEntry(methodsBlock, name);
const args = methodEntry
? extractMethodArguments(methodEntry, content)
? extractMethodArguments(methodEntry, fullContent)
: [];

methods.push({ name, description, arguments: args });
Expand All @@ -360,6 +386,61 @@ function extractMethods(content: string): ExtractedMethod[] {
return methods;
}

/**
* Resolves the variable name referenced by the `methods` property.
* Returns the variable name for `methods: someVar` or `methods` (shorthand).
* Returns null if methods are defined inline or not found.
*/
function resolveMethodsReference(content: string): string | null {
// Match shorthand property: methods, or methods } (end of object)
// This pattern appears when an imported variable named `methods` is used
// as a shorthand property in the model object literal.
const shorthandMatch = content.match(
/(?:export\s+const\s+(?:model|extension)\s*=\s*\{[\s\S]*?)(?<![.\w])methods\s*[,}]/,
);
if (shorthandMatch) {
// Verify it's not `methods: {` (inline) or `methods: someRef` (reference)
const beforeMethods = content.slice(
0,
(shorthandMatch.index ?? 0) + shorthandMatch[0].length,
);
if (!beforeMethods.match(/methods\s*:\s*[{\w]/)) {
return "methods";
}
}

// Match reference: methods: someVar (not followed by { which is inline)
const refMatch = content.match(/methods:\s*(\w+)\s*[,}\n]/);
if (refMatch && refMatch[1] !== "{") {
return refMatch[1];
}

return null;
}

/**
* Finds the body of a `const varName = { ... }` or `export const varName = { ... }`
* declaration and returns the full declaration text including `varName: { ... }` wrapper
* so that extractMethodsFromBlock can find `methodName: { description: ... }` entries.
*/
function findVariableObjectBody(
content: string,
varName: string,
): string | null {
const pattern = new RegExp(
`(?:export\\s+)?(?:const|let)\\s+${varName}\\s*=\\s*\\{`,
);
const match = content.match(pattern);
if (!match || match.index === undefined) return null;

const start = match.index + match[0].length;
const body = extractBalancedBraces(content, start);
if (!body) return null;

// Wrap as `methods: { <body> }` so the method extraction regex works
return `methods: {${body}}`;
}

/**
* Extracts the full text of a single method entry from the methods block.
*/
Expand Down
Loading
Loading