refactor: complete rf-01/rf-02/rf-11 blocked todos
rf-01: add ECONNREFUSED to isTransientNetworkError in anthropic-shared.ts, aligning with the NETWORK_RE pattern in error-classifier.js rf-02: add scripts/validate-model-cost-table.mjs to report coverage gaps and price divergence between model-cost-table.js and models.generated.ts; add 'validate-cost-table' script to package.json rf-11: extract 10 pure resource-display utility functions from interactive-mode.ts into packages/coding-agent/src/modes/interactive/ resource-display.ts, reducing interactive-mode.ts by ~282 lines All 4375 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
0aaf8f2c0e
commit
338c75fc6f
5 changed files with 525 additions and 305 deletions
|
|
@ -92,6 +92,7 @@
|
|||
"sync-pkg-version": "node scripts/sync-pkg-version.cjs",
|
||||
"sync-platform-versions": "node rust-engine/scripts/sync-platform-versions.cjs",
|
||||
"validate-pack": "node scripts/validate-pack.js",
|
||||
"validate-cost-table": "node scripts/validate-model-cost-table.mjs",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"typecheck:extensions": "tsgo --noEmit --project tsconfig.extensions.json",
|
||||
"check:sf-inventory": "node scripts/check-sf-extension-inventory.mjs",
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ export function isTransientNetworkError(error: unknown): boolean {
|
|||
code === "EPIPE" ||
|
||||
code === "ETIMEDOUT" ||
|
||||
code === "ENOTFOUND" ||
|
||||
code === "ECONNREFUSED" ||
|
||||
code === "EAI_AGAIN" ||
|
||||
msg.includes("connector_closed") ||
|
||||
msg.includes("socket hang up") ||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,13 @@ import { UserMessageComponent } from "./components/user-message.js";
|
|||
import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
|
||||
import { handleAgentEvent } from "./controllers/chat-controller.js";
|
||||
import { createExtensionUIContext as buildExtensionUIContext } from "./controllers/extension-ui-controller.js";
|
||||
import {
|
||||
buildScopeGroups,
|
||||
formatDiagnostics,
|
||||
formatDisplayPath,
|
||||
formatScopeGroups,
|
||||
getShortPath,
|
||||
} from "./resource-display.js";
|
||||
import { setupEditorSubmitHandler as setupEditorSubmitHandlerController } from "./controllers/input-controller.js";
|
||||
import { updateAvailableProviderCount as updateAvailableProviderCountController } from "./controllers/model-controller.js";
|
||||
import {
|
||||
|
|
@ -986,289 +993,6 @@ export class InteractiveMode {
|
|||
// Extension System
|
||||
// =========================================================================
|
||||
|
||||
private formatDisplayPath(p: string): string {
|
||||
const home = os.homedir();
|
||||
let result = p;
|
||||
|
||||
// Replace home directory with ~
|
||||
if (result.startsWith(home)) {
|
||||
result = `~${result.slice(home.length)}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a short path relative to the package root for display.
|
||||
*/
|
||||
private getShortPath(fullPath: string, source: string): string {
|
||||
// For npm packages, show path relative to node_modules/pkg/
|
||||
const npmMatch = fullPath.match(
|
||||
/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/,
|
||||
);
|
||||
if (npmMatch && source.startsWith("npm:")) {
|
||||
return npmMatch[2];
|
||||
}
|
||||
|
||||
// For git packages, show path relative to repo root
|
||||
const gitMatch = fullPath.match(/git\/[^/]+\/[^/]+\/(.*)/);
|
||||
if (gitMatch && source.startsWith("git:")) {
|
||||
return gitMatch[1];
|
||||
}
|
||||
|
||||
// For local/auto, just use formatDisplayPath
|
||||
return this.formatDisplayPath(fullPath);
|
||||
}
|
||||
|
||||
private getDisplaySourceInfo(
|
||||
source: string,
|
||||
scope: string,
|
||||
): { label: string; scopeLabel?: string; color: "accent" | "muted" } {
|
||||
if (source === "local") {
|
||||
if (scope === "user") {
|
||||
return { label: "user", color: "muted" };
|
||||
}
|
||||
if (scope === "project") {
|
||||
return { label: "project", color: "muted" };
|
||||
}
|
||||
if (scope === "temporary") {
|
||||
return { label: "path", scopeLabel: "temp", color: "muted" };
|
||||
}
|
||||
return { label: "path", color: "muted" };
|
||||
}
|
||||
|
||||
if (source === "cli") {
|
||||
return {
|
||||
label: "path",
|
||||
scopeLabel: scope === "temporary" ? "temp" : undefined,
|
||||
color: "muted",
|
||||
};
|
||||
}
|
||||
|
||||
const scopeLabel =
|
||||
scope === "user"
|
||||
? "user"
|
||||
: scope === "project"
|
||||
? "project"
|
||||
: scope === "temporary"
|
||||
? "temp"
|
||||
: undefined;
|
||||
return { label: source, scopeLabel, color: "accent" };
|
||||
}
|
||||
|
||||
private getScopeGroup(
|
||||
source: string,
|
||||
scope: string,
|
||||
): "user" | "project" | "path" {
|
||||
if (source === "cli" || scope === "temporary") return "path";
|
||||
if (scope === "user") return "user";
|
||||
if (scope === "project") return "project";
|
||||
return "path";
|
||||
}
|
||||
|
||||
private isPackageSource(source: string): boolean {
|
||||
return source.startsWith("npm:") || source.startsWith("git:");
|
||||
}
|
||||
|
||||
private buildScopeGroups(
|
||||
paths: string[],
|
||||
metadata: Map<string, { source: string; scope: string; origin: string }>,
|
||||
): Array<{
|
||||
scope: "user" | "project" | "path";
|
||||
paths: string[];
|
||||
packages: Map<string, string[]>;
|
||||
}> {
|
||||
const groups: Record<
|
||||
"user" | "project" | "path",
|
||||
{
|
||||
scope: "user" | "project" | "path";
|
||||
paths: string[];
|
||||
packages: Map<string, string[]>;
|
||||
}
|
||||
> = {
|
||||
user: { scope: "user", paths: [], packages: new Map() },
|
||||
project: { scope: "project", paths: [], packages: new Map() },
|
||||
path: { scope: "path", paths: [], packages: new Map() },
|
||||
};
|
||||
|
||||
for (const p of paths) {
|
||||
const meta = this.findMetadata(p, metadata);
|
||||
const source = meta?.source ?? "local";
|
||||
const scope = meta?.scope ?? "project";
|
||||
const groupKey = this.getScopeGroup(source, scope);
|
||||
const group = groups[groupKey];
|
||||
|
||||
if (this.isPackageSource(source)) {
|
||||
const list = group.packages.get(source) ?? [];
|
||||
list.push(p);
|
||||
group.packages.set(source, list);
|
||||
} else {
|
||||
group.paths.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
return [groups.project, groups.user, groups.path].filter(
|
||||
(group) => group.paths.length > 0 || group.packages.size > 0,
|
||||
);
|
||||
}
|
||||
|
||||
private formatScopeGroups(
|
||||
groups: Array<{
|
||||
scope: "user" | "project" | "path";
|
||||
paths: string[];
|
||||
packages: Map<string, string[]>;
|
||||
}>,
|
||||
options: {
|
||||
formatPath: (p: string) => string;
|
||||
formatPackagePath: (p: string, source: string) => string;
|
||||
},
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const group of groups) {
|
||||
lines.push(` ${theme.fg("accent", group.scope)}`);
|
||||
|
||||
const sortedPaths = [...group.paths].sort((a, b) => a.localeCompare(b));
|
||||
for (const p of sortedPaths) {
|
||||
lines.push(theme.fg("dim", ` ${options.formatPath(p)}`));
|
||||
}
|
||||
|
||||
const sortedPackages = Array.from(group.packages.entries()).sort(
|
||||
([a], [b]) => a.localeCompare(b),
|
||||
);
|
||||
for (const [source, paths] of sortedPackages) {
|
||||
lines.push(` ${theme.fg("mdLink", source)}`);
|
||||
const sortedPackagePaths = [...paths].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
for (const p of sortedPackagePaths) {
|
||||
lines.push(
|
||||
theme.fg("dim", ` ${options.formatPackagePath(p, source)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Find metadata for a path, checking parent directories if exact match fails.
|
||||
* Package manager stores metadata for directories, but we display file paths.
|
||||
*/
|
||||
private findMetadata(
|
||||
p: string,
|
||||
metadata: Map<string, { source: string; scope: string; origin: string }>,
|
||||
): { source: string; scope: string; origin: string } | undefined {
|
||||
// Try exact match first
|
||||
const exact = metadata.get(p);
|
||||
if (exact) return exact;
|
||||
|
||||
// Try parent directories (package manager stores directory paths)
|
||||
let current = p;
|
||||
let parent = path.dirname(current);
|
||||
while (parent !== current) {
|
||||
const meta = metadata.get(parent);
|
||||
if (meta) return meta;
|
||||
current = parent;
|
||||
parent = path.dirname(current);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a path with its source/scope info from metadata.
|
||||
*/
|
||||
private formatPathWithSource(
|
||||
p: string,
|
||||
metadata: Map<string, { source: string; scope: string; origin: string }>,
|
||||
): string {
|
||||
const meta = this.findMetadata(p, metadata);
|
||||
if (meta) {
|
||||
const shortPath = this.getShortPath(p, meta.source);
|
||||
const { label, scopeLabel } = this.getDisplaySourceInfo(
|
||||
meta.source,
|
||||
meta.scope,
|
||||
);
|
||||
const labelText = scopeLabel ? `${label} (${scopeLabel})` : label;
|
||||
return `${labelText} ${shortPath}`;
|
||||
}
|
||||
return this.formatDisplayPath(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format resource diagnostics with nice collision display using metadata.
|
||||
*/
|
||||
private formatDiagnostics(
|
||||
diagnostics: readonly ResourceDiagnostic[],
|
||||
metadata: Map<string, { source: string; scope: string; origin: string }>,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Group collision diagnostics by name
|
||||
const collisions = new Map<string, ResourceDiagnostic[]>();
|
||||
const otherDiagnostics: ResourceDiagnostic[] = [];
|
||||
|
||||
for (const d of diagnostics) {
|
||||
if (d.type === "collision" && d.collision) {
|
||||
const list = collisions.get(d.collision.name) ?? [];
|
||||
list.push(d);
|
||||
collisions.set(d.collision.name, list);
|
||||
} else {
|
||||
otherDiagnostics.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
// Format collision diagnostics grouped by name
|
||||
for (const [name, collisionList] of collisions) {
|
||||
const first = collisionList[0]?.collision;
|
||||
if (!first) continue;
|
||||
lines.push(theme.fg("warning", ` "${name}" collision:`));
|
||||
// Show winner
|
||||
lines.push(
|
||||
theme.fg(
|
||||
"dim",
|
||||
` ${theme.fg("success", "✓")} ${this.formatPathWithSource(first.winnerPath, metadata)}`,
|
||||
),
|
||||
);
|
||||
// Show all losers
|
||||
for (const d of collisionList) {
|
||||
if (d.collision) {
|
||||
lines.push(
|
||||
theme.fg(
|
||||
"dim",
|
||||
` ${theme.fg("warning", "✗")} ${this.formatPathWithSource(d.collision.loserPath, metadata)} (skipped)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format other diagnostics (skill name collisions, parse errors, etc.)
|
||||
for (const d of otherDiagnostics) {
|
||||
if (d.path) {
|
||||
// Use metadata-aware formatting for paths
|
||||
const sourceInfo = this.formatPathWithSource(d.path, metadata);
|
||||
lines.push(
|
||||
theme.fg(d.type === "error" ? "error" : "warning", ` ${sourceInfo}`),
|
||||
);
|
||||
lines.push(
|
||||
theme.fg(
|
||||
d.type === "error" ? "error" : "warning",
|
||||
` ${d.message}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
private showLoadedResources(options?: {
|
||||
extensionPaths?: string[];
|
||||
force?: boolean;
|
||||
|
|
@ -1298,7 +1022,7 @@ export class InteractiveMode {
|
|||
if (contextFiles.length > 0) {
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
const contextList = contextFiles
|
||||
.map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`))
|
||||
.map((f) => theme.fg("dim", ` ${formatDisplayPath(f.path)}`))
|
||||
.join("\n");
|
||||
this.chatContainer.addChild(
|
||||
new Text(`${sectionHeader("Context")}\n${contextList}`, 0, 0),
|
||||
|
|
@ -1309,10 +1033,10 @@ export class InteractiveMode {
|
|||
const skills = skillsResult.skills;
|
||||
if (skills.length > 0) {
|
||||
const skillPaths = skills.map((s) => s.filePath);
|
||||
const groups = this.buildScopeGroups(skillPaths, metadata);
|
||||
const skillList = this.formatScopeGroups(groups, {
|
||||
formatPath: (p) => this.formatDisplayPath(p),
|
||||
formatPackagePath: (p, source) => this.getShortPath(p, source),
|
||||
const groups = buildScopeGroups(skillPaths, metadata);
|
||||
const skillList = formatScopeGroups(groups, {
|
||||
formatPath: (p) => formatDisplayPath(p),
|
||||
formatPackagePath: (p, source) => getShortPath(p, source),
|
||||
});
|
||||
this.chatContainer.addChild(
|
||||
new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0),
|
||||
|
|
@ -1323,16 +1047,16 @@ export class InteractiveMode {
|
|||
const templates = this.session.promptTemplates;
|
||||
if (templates.length > 0) {
|
||||
const templatePaths = templates.map((t) => t.filePath);
|
||||
const groups = this.buildScopeGroups(templatePaths, metadata);
|
||||
const groups = buildScopeGroups(templatePaths, metadata);
|
||||
const templateByPath = new Map(templates.map((t) => [t.filePath, t]));
|
||||
const templateList = this.formatScopeGroups(groups, {
|
||||
const templateList = formatScopeGroups(groups, {
|
||||
formatPath: (p) => {
|
||||
const template = templateByPath.get(p);
|
||||
return template ? `/${template.name}` : this.formatDisplayPath(p);
|
||||
return template ? `/${template.name}` : formatDisplayPath(p);
|
||||
},
|
||||
formatPackagePath: (p) => {
|
||||
const template = templateByPath.get(p);
|
||||
return template ? `/${template.name}` : this.formatDisplayPath(p);
|
||||
return template ? `/${template.name}` : formatDisplayPath(p);
|
||||
},
|
||||
});
|
||||
this.chatContainer.addChild(
|
||||
|
|
@ -1343,10 +1067,10 @@ export class InteractiveMode {
|
|||
|
||||
const extensionPaths = options?.extensionPaths ?? [];
|
||||
if (extensionPaths.length > 0) {
|
||||
const groups = this.buildScopeGroups(extensionPaths, metadata);
|
||||
const extList = this.formatScopeGroups(groups, {
|
||||
formatPath: (p) => this.formatDisplayPath(p),
|
||||
formatPackagePath: (p, source) => this.getShortPath(p, source),
|
||||
const groups = buildScopeGroups(extensionPaths, metadata);
|
||||
const extList = formatScopeGroups(groups, {
|
||||
formatPath: (p) => formatDisplayPath(p),
|
||||
formatPackagePath: (p, source) => getShortPath(p, source),
|
||||
});
|
||||
this.chatContainer.addChild(
|
||||
new Text(
|
||||
|
|
@ -1363,10 +1087,10 @@ export class InteractiveMode {
|
|||
const customThemes = loadedThemes.filter((t) => t.sourcePath);
|
||||
if (customThemes.length > 0) {
|
||||
const themePaths = customThemes.map((t) => t.sourcePath!);
|
||||
const groups = this.buildScopeGroups(themePaths, metadata);
|
||||
const themeList = this.formatScopeGroups(groups, {
|
||||
formatPath: (p) => this.formatDisplayPath(p),
|
||||
formatPackagePath: (p, source) => this.getShortPath(p, source),
|
||||
const groups = buildScopeGroups(themePaths, metadata);
|
||||
const themeList = formatScopeGroups(groups, {
|
||||
formatPath: (p) => formatDisplayPath(p),
|
||||
formatPackagePath: (p, source) => getShortPath(p, source),
|
||||
});
|
||||
this.chatContainer.addChild(
|
||||
new Text(`${sectionHeader("Themes")}\n${themeList}`, 0, 0),
|
||||
|
|
@ -1386,7 +1110,7 @@ export class InteractiveMode {
|
|||
);
|
||||
|
||||
if (collisionDiags.length > 0) {
|
||||
const collisionLines = this.formatDiagnostics(
|
||||
const collisionLines = formatDiagnostics(
|
||||
collisionDiags,
|
||||
metadata,
|
||||
);
|
||||
|
|
@ -1401,7 +1125,7 @@ export class InteractiveMode {
|
|||
}
|
||||
|
||||
if (issueDiags.length > 0) {
|
||||
const issueLines = this.formatDiagnostics(issueDiags, metadata);
|
||||
const issueLines = formatDiagnostics(issueDiags, metadata);
|
||||
this.chatContainer.addChild(
|
||||
new Text(
|
||||
`${theme.fg("warning", "[Skill issues]")}\n${issueLines}`,
|
||||
|
|
@ -1415,7 +1139,7 @@ export class InteractiveMode {
|
|||
|
||||
const promptDiagnostics = promptsResult.diagnostics;
|
||||
if (promptDiagnostics.length > 0) {
|
||||
const warningLines = this.formatDiagnostics(
|
||||
const warningLines = formatDiagnostics(
|
||||
promptDiagnostics,
|
||||
metadata,
|
||||
);
|
||||
|
|
@ -1451,7 +1175,7 @@ export class InteractiveMode {
|
|||
extensionDiagnostics.push(...shortcutDiagnostics);
|
||||
|
||||
if (extensionDiagnostics.length > 0) {
|
||||
const warningLines = this.formatDiagnostics(
|
||||
const warningLines = formatDiagnostics(
|
||||
extensionDiagnostics,
|
||||
metadata,
|
||||
);
|
||||
|
|
@ -1467,7 +1191,7 @@ export class InteractiveMode {
|
|||
|
||||
const themeDiagnostics = themesResult.diagnostics;
|
||||
if (themeDiagnostics.length > 0) {
|
||||
const warningLines = this.formatDiagnostics(themeDiagnostics, metadata);
|
||||
const warningLines = formatDiagnostics(themeDiagnostics, metadata);
|
||||
this.chatContainer.addChild(
|
||||
new Text(
|
||||
`${theme.fg("warning", "[Theme conflicts]")}\n${warningLines}`,
|
||||
|
|
|
|||
294
packages/coding-agent/src/modes/interactive/resource-display.ts
Normal file
294
packages/coding-agent/src/modes/interactive/resource-display.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
/**
|
||||
* resource-display.ts — utility functions for formatting loaded-resource paths and diagnostics.
|
||||
*
|
||||
* Purpose: isolate the pure display-formatting logic that was previously embedded as private
|
||||
* methods in InteractiveMode. These functions have no side-effects and take all their inputs
|
||||
* as parameters so they can be unit-tested and reused without a class instance.
|
||||
*
|
||||
* Consumer: interactive-mode.ts (showLoadedResources, formatDiagnostics callsites).
|
||||
*/
|
||||
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { ResourceDiagnostic } from "../../core/resource-loader.js";
|
||||
import { theme } from "./theme/theme.js";
|
||||
|
||||
/** Replace the home directory prefix with `~` for compact display. */
|
||||
export function formatDisplayPath(p: string): string {
|
||||
const home = os.homedir();
|
||||
let result = p;
|
||||
if (result.startsWith(home)) {
|
||||
result = `~${result.slice(home.length)}`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a short path relative to the package root for display.
|
||||
* npm packages show a path relative to node_modules/pkg/; git packages relative to repo root;
|
||||
* local paths fall back to formatDisplayPath.
|
||||
*/
|
||||
export function getShortPath(fullPath: string, source: string): string {
|
||||
const npmMatch = fullPath.match(
|
||||
/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/,
|
||||
);
|
||||
if (npmMatch && source.startsWith("npm:")) {
|
||||
return npmMatch[2];
|
||||
}
|
||||
|
||||
const gitMatch = fullPath.match(/git\/[^/]+\/[^/]+\/(.*)/);
|
||||
if (gitMatch && source.startsWith("git:")) {
|
||||
return gitMatch[1];
|
||||
}
|
||||
|
||||
return formatDisplayPath(fullPath);
|
||||
}
|
||||
|
||||
/** Map a resource source + scope to a human-readable label and accent color. */
|
||||
export function getDisplaySourceInfo(
|
||||
source: string,
|
||||
scope: string,
|
||||
): { label: string; scopeLabel?: string; color: "accent" | "muted" } {
|
||||
if (source === "local") {
|
||||
if (scope === "user") {
|
||||
return { label: "user", color: "muted" };
|
||||
}
|
||||
if (scope === "project") {
|
||||
return { label: "project", color: "muted" };
|
||||
}
|
||||
if (scope === "temporary") {
|
||||
return { label: "path", scopeLabel: "temp", color: "muted" };
|
||||
}
|
||||
return { label: "path", color: "muted" };
|
||||
}
|
||||
|
||||
if (source === "cli") {
|
||||
return {
|
||||
label: "path",
|
||||
scopeLabel: scope === "temporary" ? "temp" : undefined,
|
||||
color: "muted",
|
||||
};
|
||||
}
|
||||
|
||||
const scopeLabel =
|
||||
scope === "user"
|
||||
? "user"
|
||||
: scope === "project"
|
||||
? "project"
|
||||
: scope === "temporary"
|
||||
? "temp"
|
||||
: undefined;
|
||||
return { label: source, scopeLabel, color: "accent" };
|
||||
}
|
||||
|
||||
/** Classify a resource source+scope into one of the three display groups. */
|
||||
export function getScopeGroup(
|
||||
source: string,
|
||||
scope: string,
|
||||
): "user" | "project" | "path" {
|
||||
if (source === "cli" || scope === "temporary") return "path";
|
||||
if (scope === "user") return "user";
|
||||
if (scope === "project") return "project";
|
||||
return "path";
|
||||
}
|
||||
|
||||
/** True when the source is an npm or git package (i.e. managed by the package manager). */
|
||||
export function isPackageSource(source: string): boolean {
|
||||
return source.startsWith("npm:") || source.startsWith("git:");
|
||||
}
|
||||
|
||||
/**
|
||||
* Group a list of resource paths by their user/project/path scope bucket.
|
||||
* Within each bucket, package-manager sources are further grouped by package.
|
||||
*/
|
||||
export function buildScopeGroups(
|
||||
paths: string[],
|
||||
metadata: Map<string, { source: string; scope: string; origin: string }>,
|
||||
): Array<{
|
||||
scope: "user" | "project" | "path";
|
||||
paths: string[];
|
||||
packages: Map<string, string[]>;
|
||||
}> {
|
||||
const groups: Record<
|
||||
"user" | "project" | "path",
|
||||
{
|
||||
scope: "user" | "project" | "path";
|
||||
paths: string[];
|
||||
packages: Map<string, string[]>;
|
||||
}
|
||||
> = {
|
||||
user: { scope: "user", paths: [], packages: new Map() },
|
||||
project: { scope: "project", paths: [], packages: new Map() },
|
||||
path: { scope: "path", paths: [], packages: new Map() },
|
||||
};
|
||||
|
||||
for (const p of paths) {
|
||||
const meta = findMetadata(p, metadata);
|
||||
const source = meta?.source ?? "local";
|
||||
const scope = meta?.scope ?? "project";
|
||||
const groupKey = getScopeGroup(source, scope);
|
||||
const group = groups[groupKey];
|
||||
|
||||
if (isPackageSource(source)) {
|
||||
const list = group.packages.get(source) ?? [];
|
||||
list.push(p);
|
||||
group.packages.set(source, list);
|
||||
} else {
|
||||
group.paths.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
return [groups.project, groups.user, groups.path].filter(
|
||||
(group) => group.paths.length > 0 || group.packages.size > 0,
|
||||
);
|
||||
}
|
||||
|
||||
/** Render scope groups into a multi-line indented string for display. */
|
||||
export function formatScopeGroups(
|
||||
groups: Array<{
|
||||
scope: "user" | "project" | "path";
|
||||
paths: string[];
|
||||
packages: Map<string, string[]>;
|
||||
}>,
|
||||
options: {
|
||||
formatPath: (p: string) => string;
|
||||
formatPackagePath: (p: string, source: string) => string;
|
||||
},
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const group of groups) {
|
||||
lines.push(` ${theme.fg("accent", group.scope)}`);
|
||||
|
||||
const sortedPaths = [...group.paths].sort((a, b) => a.localeCompare(b));
|
||||
for (const p of sortedPaths) {
|
||||
lines.push(theme.fg("dim", ` ${options.formatPath(p)}`));
|
||||
}
|
||||
|
||||
const sortedPackages = Array.from(group.packages.entries()).sort(
|
||||
([a], [b]) => a.localeCompare(b),
|
||||
);
|
||||
for (const [source, paths] of sortedPackages) {
|
||||
lines.push(` ${theme.fg("mdLink", source)}`);
|
||||
const sortedPackagePaths = [...paths].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
for (const p of sortedPackagePaths) {
|
||||
lines.push(
|
||||
theme.fg("dim", ` ${options.formatPackagePath(p, source)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Find metadata for a path, checking parent directories if exact match fails.
|
||||
* The package manager stores metadata for directories, but we display file paths.
|
||||
*/
|
||||
export function findMetadata(
|
||||
p: string,
|
||||
metadata: Map<string, { source: string; scope: string; origin: string }>,
|
||||
): { source: string; scope: string; origin: string } | undefined {
|
||||
const exact = metadata.get(p);
|
||||
if (exact) return exact;
|
||||
|
||||
let current = p;
|
||||
let parent = path.dirname(current);
|
||||
while (parent !== current) {
|
||||
const meta = metadata.get(parent);
|
||||
if (meta) return meta;
|
||||
current = parent;
|
||||
parent = path.dirname(current);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Format a path with its source/scope info derived from loader metadata. */
|
||||
export function formatPathWithSource(
|
||||
p: string,
|
||||
metadata: Map<string, { source: string; scope: string; origin: string }>,
|
||||
): string {
|
||||
const meta = findMetadata(p, metadata);
|
||||
if (meta) {
|
||||
const shortPath = getShortPath(p, meta.source);
|
||||
const { label, scopeLabel } = getDisplaySourceInfo(meta.source, meta.scope);
|
||||
const labelText = scopeLabel ? `${label} (${scopeLabel})` : label;
|
||||
return `${labelText} ${shortPath}`;
|
||||
}
|
||||
return formatDisplayPath(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format resource diagnostics with nice collision display using metadata.
|
||||
*
|
||||
* Purpose: render skill/prompt/extension load errors and name collisions into
|
||||
* a human-readable multi-line string so showLoadedResources can add them to chat.
|
||||
*
|
||||
* Consumer: interactive-mode.ts showLoadedResources.
|
||||
*/
|
||||
export function formatDiagnostics(
|
||||
diagnostics: readonly ResourceDiagnostic[],
|
||||
metadata: Map<string, { source: string; scope: string; origin: string }>,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
const collisions = new Map<string, ResourceDiagnostic[]>();
|
||||
const otherDiagnostics: ResourceDiagnostic[] = [];
|
||||
|
||||
for (const d of diagnostics) {
|
||||
if (d.type === "collision" && d.collision) {
|
||||
const list = collisions.get(d.collision.name) ?? [];
|
||||
list.push(d);
|
||||
collisions.set(d.collision.name, list);
|
||||
} else {
|
||||
otherDiagnostics.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, collisionList] of collisions) {
|
||||
const first = collisionList[0]?.collision;
|
||||
if (!first) continue;
|
||||
lines.push(theme.fg("warning", ` "${name}" collision:`));
|
||||
lines.push(
|
||||
theme.fg(
|
||||
"dim",
|
||||
` ${theme.fg("success", "✓")} ${formatPathWithSource(first.winnerPath, metadata)}`,
|
||||
),
|
||||
);
|
||||
for (const d of collisionList) {
|
||||
if (d.collision) {
|
||||
lines.push(
|
||||
theme.fg(
|
||||
"dim",
|
||||
` ${theme.fg("warning", "✗")} ${formatPathWithSource(d.collision.loserPath, metadata)} (skipped)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const d of otherDiagnostics) {
|
||||
if (d.path) {
|
||||
const sourceInfo = formatPathWithSource(d.path, metadata);
|
||||
lines.push(
|
||||
theme.fg(d.type === "error" ? "error" : "warning", ` ${sourceInfo}`),
|
||||
);
|
||||
lines.push(
|
||||
theme.fg(
|
||||
d.type === "error" ? "error" : "warning",
|
||||
` ${d.message}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
200
scripts/validate-model-cost-table.mjs
Normal file
200
scripts/validate-model-cost-table.mjs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* validate-model-cost-table.mjs
|
||||
*
|
||||
* Purpose: verify that every model in packages/ai/src/models.generated.ts that is
|
||||
* also in src/resources/extensions/sf/model-cost-table.js has a matching entry and
|
||||
* report models present in models.generated.ts but missing from the extension table.
|
||||
*
|
||||
* The two tables intentionally use different schemas:
|
||||
* models.generated.ts : cost.input / cost.output in $/million tokens (auto-generated)
|
||||
* model-cost-table.js : inputPer1k / outputPer1k in $/1K tokens (hand-maintained)
|
||||
*
|
||||
* This script catches coverage gaps and price divergence > 5% so engineers know when
|
||||
* the extension table needs updating after a models.generated.ts regeneration.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/validate-model-cost-table.mjs
|
||||
* node scripts/validate-model-cost-table.mjs --threshold 10 # % diff threshold
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolve, dirname } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, "..");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse the extension cost table (JS module — use dynamic import)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const costTablePath = resolve(
|
||||
root,
|
||||
"src/resources/extensions/sf/model-cost-table.js",
|
||||
);
|
||||
|
||||
// The cost table uses ES module exports — eval it via a data: URL import
|
||||
const rawCostTable = readFileSync(costTablePath, "utf8");
|
||||
|
||||
// Strip JSDoc comments and extract the array literal safely using regex
|
||||
// This avoids spinning up a full module system just for a static data file.
|
||||
const arrayMatch = rawCostTable.match(
|
||||
/export const BUNDLED_COST_TABLE\s*=\s*(\[[\s\S]*?\]);/,
|
||||
);
|
||||
if (!arrayMatch) {
|
||||
console.error(
|
||||
"❌ Could not parse BUNDLED_COST_TABLE from model-cost-table.js",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let extensionTable;
|
||||
try {
|
||||
// biome-ignore lint/security/noEval: parsing a static, local-file data literal
|
||||
extensionTable = eval(`(${arrayMatch[1]})`);
|
||||
} catch (err) {
|
||||
console.error("❌ Failed to evaluate BUNDLED_COST_TABLE:", err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** @type {Map<string, { inputPer1k: number; outputPer1k: number }>} */
|
||||
const extensionById = new Map(
|
||||
extensionTable.map((entry) => [
|
||||
entry.id,
|
||||
{ inputPer1k: entry.inputPer1k, outputPer1k: entry.outputPer1k },
|
||||
]),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse models.generated.ts (text scan — avoid TS compilation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const generatedPath = resolve(root, "packages/ai/src/models.generated.ts");
|
||||
const generatedSrc = readFileSync(generatedPath, "utf8");
|
||||
|
||||
// Extract all model blocks: "model-id": { ... cost: { input: N, output: N ... } ... }
|
||||
// Use a simple regex to find id + cost pairs; not a full parser but sufficient for CI.
|
||||
const modelBlocks = [];
|
||||
// Match patterns like: "model-id": { ... cost: { input: X, output: Y ... }
|
||||
const blockRe =
|
||||
/"([a-zA-Z0-9._:/@-]+)":\s*\{[^{}]*?cost:\s*\{[^{}]*?input:\s*([\d.]+)[^{}]*?output:\s*([\d.]+)[^{}]*?\}/gs;
|
||||
let match;
|
||||
while ((match = blockRe.exec(generatedSrc)) !== null) {
|
||||
const [, id, inputStr, outputStr] = match;
|
||||
const input = parseFloat(inputStr);
|
||||
const output = parseFloat(outputStr);
|
||||
if (!Number.isNaN(input) && !Number.isNaN(output)) {
|
||||
modelBlocks.push({ id, inputPerM: input, outputPerM: output });
|
||||
}
|
||||
}
|
||||
|
||||
// De-duplicate (same model id can appear under multiple providers in generated file)
|
||||
/** @type {Map<string, { inputPerM: number; outputPerM: number }>} */
|
||||
const generatedById = new Map();
|
||||
for (const { id, inputPerM, outputPerM } of modelBlocks) {
|
||||
if (!generatedById.has(id)) {
|
||||
generatedById.set(id, { inputPerM, outputPerM });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compare coverage and prices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const thresholdArg = process.argv.indexOf("--threshold");
|
||||
const threshold =
|
||||
thresholdArg !== -1 ? parseFloat(process.argv[thresholdArg + 1]) : 5;
|
||||
|
||||
const missing = [];
|
||||
const diverged = [];
|
||||
let checked = 0;
|
||||
|
||||
for (const [id, ext] of extensionById) {
|
||||
const gen = generatedById.get(id);
|
||||
if (!gen) {
|
||||
// Model in extension table but not in generated — that's OK (extension can have extras)
|
||||
continue;
|
||||
}
|
||||
|
||||
checked++;
|
||||
// Convert generated per-million to per-1K for comparison
|
||||
const genInputPer1k = gen.inputPerM / 1000;
|
||||
const genOutputPer1k = gen.outputPerM / 1000;
|
||||
|
||||
const inputDiff =
|
||||
genInputPer1k > 0
|
||||
? Math.abs(ext.inputPer1k - genInputPer1k) / genInputPer1k
|
||||
: 0;
|
||||
const outputDiff =
|
||||
genOutputPer1k > 0
|
||||
? Math.abs(ext.outputPer1k - genOutputPer1k) / genOutputPer1k
|
||||
: 0;
|
||||
|
||||
if (inputDiff > threshold / 100 || outputDiff > threshold / 100) {
|
||||
diverged.push({
|
||||
id,
|
||||
ext,
|
||||
gen: { inputPer1k: genInputPer1k, outputPer1k: genOutputPer1k },
|
||||
inputDiff: (inputDiff * 100).toFixed(1),
|
||||
outputDiff: (outputDiff * 100).toFixed(1),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also report models in generated that are missing from extension table
|
||||
// (only for non-zero-cost models since zero-cost models like local/self-hosted are OK to omit)
|
||||
for (const [id, gen] of generatedById) {
|
||||
if (!extensionById.has(id) && gen.inputPerM > 0) {
|
||||
missing.push({ id, gen });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Report
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let hasIssues = false;
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.log(
|
||||
`\n⚠️ ${missing.length} model(s) in models.generated.ts missing from model-cost-table.js:\n`,
|
||||
);
|
||||
for (const { id, gen } of missing) {
|
||||
const inputPer1k = (gen.inputPerM / 1000).toFixed(6);
|
||||
const outputPer1k = (gen.outputPerM / 1000).toFixed(6);
|
||||
console.log(
|
||||
` ${id} → inputPer1k: ${inputPer1k}, outputPer1k: ${outputPer1k}`,
|
||||
);
|
||||
}
|
||||
hasIssues = true;
|
||||
}
|
||||
|
||||
if (diverged.length > 0) {
|
||||
console.log(
|
||||
`\n⚠️ ${diverged.length} model(s) with price divergence > ${threshold}% between tables:\n`,
|
||||
);
|
||||
for (const { id, ext, gen, inputDiff, outputDiff } of diverged) {
|
||||
console.log(` ${id}`);
|
||||
console.log(
|
||||
` extension: inputPer1k=${ext.inputPer1k}, outputPer1k=${ext.outputPer1k}`,
|
||||
);
|
||||
console.log(
|
||||
` generated: inputPer1k=${gen.inputPer1k.toFixed(6)}, outputPer1k=${gen.outputPer1k.toFixed(6)}`,
|
||||
);
|
||||
console.log(` diff: input=${inputDiff}%, output=${outputDiff}%`);
|
||||
}
|
||||
hasIssues = true;
|
||||
}
|
||||
|
||||
if (!hasIssues) {
|
||||
console.log(
|
||||
`✅ model-cost-table.js is consistent with models.generated.ts (${checked} models checked, ${missing.length} missing, divergence threshold ${threshold}%)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Exit 0 even with issues — this is informational, not a hard gate.
|
||||
// Set STRICT=1 to fail CI on divergence.
|
||||
if (hasIssues && process.env.STRICT === "1") {
|
||||
process.exit(1);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue