Skip to content
Open
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
36 changes: 33 additions & 3 deletions packages/base/markdown-file-def.gts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
byteStreamToUint8Array,
extractCardReferenceUrls,
FRONTMATTER_PARSE_ERROR_SYMBOL,
identifyCard,
VirtualNetwork,
type FrontmatterParseError,
} from '@cardstack/runtime-common';
import MarkdownIcon from '@cardstack/boxel-icons/align-box-left-middle';
import {
Expand Down Expand Up @@ -36,6 +38,21 @@ import { parseFrontmatter } from './frontmatter-parse';
// the same global symbol. See `file-def-attributes-extractor.ts`.
const fileFieldMetaSymbol = Symbol.for('boxel:file-field-meta');

// Best-effort structured view of a YAML parse failure. The `yaml` library
// throws a `YAMLParseError` carrying `linePos` (`[{ line, col }, …]`); read it
// defensively so a non-YAMLParseError still yields a usable message.
function toFrontmatterParseError(err: unknown): FrontmatterParseError {
let message =
err instanceof Error ? err.message : `Frontmatter parse failed: ${err}`;
let pos = (err as { linePos?: Array<{ line?: number; col?: number }> })
?.linePos?.[0];
return {
message,
...(typeof pos?.line === 'number' ? { line: pos.line } : {}),
...(typeof pos?.col === 'number' ? { column: pos.col } : {}),
};
}

const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown']);
const EXCERPT_MAX_LENGTH = 500;

Expand Down Expand Up @@ -549,15 +566,22 @@ export class MarkdownDef extends FileDef {

let frontmatterData: Record<string, unknown> = {};
let body = markdown;
let frontmatterParseError: FrontmatterParseError | undefined;
try {
let parsed = parseFrontmatter(normalizeMarkdown(markdown));
frontmatterData = parsed.data;
body = parsed.body;
} catch (err) {
// Invalid YAML: index the markdown without frontmatter rather than fail
// the whole file. TODO: surface this via indexing diagnostics rather than
// only a console warning, so frontmatter parse errors stay visible.
console.warn(`[markdown-file-def] frontmatter parse failed for ${url}:`, err);
// the whole file, but capture the failure so it surfaces via indexing
// diagnostics (CS-11548) instead of silently dropping whatever the
// frontmatter declared (e.g. a skill's commands). Routed out-of-band via
// `FRONTMATTER_PARSE_ERROR_SYMBOL`, picked up by the host file extractor.
frontmatterParseError = toFrontmatterParseError(err);
console.warn(
`[markdown-file-def] frontmatter parse failed for ${url}:`,
err,
);
}

let attributes: SerializedFile<{
Expand Down Expand Up @@ -617,6 +641,12 @@ export class MarkdownDef extends FileDef {
}
}

if (frontmatterParseError) {
(attributes as Record<PropertyKey, unknown>)[
FRONTMATTER_PARSE_ERROR_SYMBOL
] = frontmatterParseError;
}

return attributes;
}
}
58 changes: 45 additions & 13 deletions packages/boxel-cli/plugin/skills/indexing-errors/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
---
description: List every `boxel_index` row in a realm whose latest indexing attempt failed (`has_error = TRUE`) OR rendered cleanly but holds broken `linksTo` / `linksToMany` targets. Use to answer "why is this realm broken", "what's failing to index", or "which cards have dead references" without reading the database directly.
description: List every `boxel_index` row in a realm whose latest indexing attempt failed (`has_error = TRUE`), rendered cleanly but holds broken `linksTo` / `linksToMany` targets, or indexed but couldn't parse its frontmatter YAML. Use to answer "why is this realm broken", "what's failing to index", "which cards have dead references", or "why did my skill's commands disappear" without reading the database directly.
---

# Realm indexing errors

`boxel realm indexing-errors` returns two classes of "indexing finding" for a realm:
`boxel realm indexing-errors` returns three classes of "indexing finding" for a realm:

1. **`indexing-error`** — `boxel_index` rows where `has_error = TRUE`. The render or file extract failed and the persisted `errorDoc` (a `SerializedError`: `message`, `status`, `title`, optional `stack`, optional `deps`) explains why.
2. **`broken-link`** — `boxel_index` rows where `has_error = FALSE` but the indexer's `render.meta` scan persisted a non-empty `diagnostics.brokenLinks` array. The card itself indexes fine; one or more `linksTo` / `linksToMany` targets are dead. Each entry exposes `attributes.brokenLinks: BrokenLinkSummary[]` — `{ fieldName, reference, kind: 'error' | 'not-found' }`.
3. **`frontmatter-error`** — `boxel_index` rows where `has_error = FALSE` but a markdown file's leading YAML frontmatter wouldn't parse, persisted as `diagnostics.frontmatterParseError`. The file indexes body-only, so anything the frontmatter declared (a skill's `commands`, its `boxel.kind`, etc.) was silently dropped — this finding is the only signal the author gets. Each entry exposes `attributes.frontmatterParseError` — `{ message, line?, column? }`.

Use this skill to triage realm health in one call, without reading the database directly or scraping per-card failures.

## When the user asks to...

| Ask | Run |
|---|---|
| "what's failing to index in <realm>?" | `boxel realm indexing-errors --realm <realm-url>` |
| "which cards have broken links in <realm>?" | `boxel realm indexing-errors --realm <realm-url> --json \| jq '.data[] \| select(.type == "broken-link")'` |
| "give me the full payload as JSON" | `boxel realm indexing-errors --realm <realm-url> --json` |
| "is anything broken in this realm right now?" | `boxel realm indexing-errors --realm <realm-url>` — exit 0 with "No indexing errors." is the all-clear |
| Ask | Run |
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| "what's failing to index in <realm>?" | `boxel realm indexing-errors --realm <realm-url>` |
| "which cards have broken links in <realm>?" | `boxel realm indexing-errors --realm <realm-url> --json \| jq '.data[] \| select(.type == "broken-link")'` |
| "why did my skill's commands disappear / which files have bad frontmatter?" | `boxel realm indexing-errors --realm <realm-url> --json \| jq '.data[] \| select(.type == "frontmatter-error")'` |
| "give me the full payload as JSON" | `boxel realm indexing-errors --realm <realm-url> --json` |
| "is anything broken in this realm right now?" | `boxel realm indexing-errors --realm <realm-url>` — exit 0 with "No indexing errors." is the all-clear |

## Typical sequencing

Expand Down Expand Up @@ -57,13 +59,14 @@ List every card or module in a realm whose latest indexing attempt errored
Default (human-readable):

```
3 indexing findings for https://realm.example.com/:
4 indexing findings for https://realm.example.com/:
[instance] https://realm.example.com/broken-card.json Cannot find module './missing'
[file] https://realm.example.com/recipes.gts Unexpected token '<'
[instance] https://realm.example.com/clean-but-linked.json 2 broken: author→https://…, tags→https://…
[file] https://realm.example.com/skills/sync/SKILL.md frontmatter parse error (line 4:3): Implicit map keys need to be on a single line
```

Each line is `[<entryType>] <url> <short summary>`. For `indexing-error` rows the summary is `errorDoc.title` (falling back to `errorDoc.message`). For `broken-link` rows it is `<N> broken: fieldName→reference, …` (up to three, then `+N more`).
Each line is `[<entryType>] <url> <short summary>`. For `indexing-error` rows the summary is `errorDoc.title` (falling back to `errorDoc.message`). For `broken-link` rows it is `<N> broken: fieldName→reference, …` (up to three, then `+N more`). For `frontmatter-error` rows it is `frontmatter parse error (line L:C): <message>`.

`--json` emits a JSON-API document:

Expand All @@ -88,13 +91,41 @@ Each line is `[<entryType>] <url> <short summary>`. For `indexing-error` rows t
"entryType": "instance",
"diagnostics": {
"brokenLinks": [
{ "fieldName": "author", "reference": "https://...", "kind": "not-found" }
{
"fieldName": "author",
"reference": "https://...",
"kind": "not-found"
}
]
},
"brokenLinks": [
{ "fieldName": "author", "reference": "https://...", "kind": "not-found" }
{
"fieldName": "author",
"reference": "https://...",
"kind": "not-found"
}
]
}
},
{
"type": "frontmatter-error",
"id": "file::https://realm.example.com/skills/sync/SKILL.md",
"attributes": {
"url": "https://realm.example.com/skills/sync/SKILL.md",
"entryType": "file",
"diagnostics": {
"frontmatterParseError": {
"message": "Implicit map keys need to be on a single line",
"line": 4,
"column": 3
}
},
"frontmatterParseError": {
"message": "Implicit map keys need to be on a single line",
"line": 4,
"column": 3
}
}
}
]
}
Expand All @@ -105,7 +136,8 @@ Each line is `[<entryType>] <url> <short summary>`. For `indexing-error` rows t
- `--realm` is required and must be the realm base URL (with the realm path, not just the server origin).
- The endpoint reports findings for **all** entry types (instances, modules, files) — not just card instances. A failing `.gts` shows up here even if it isn't published as a card.
- The default human-readable line truncates the error message to 100 characters and prefers `errorDoc.title` over `errorDoc.message`. Use `--json` for the full payload.
- `broken-link` findings have **no** `errorDoc` — `has_error` is `FALSE`. Branch on `data[].type` (or check `attributes.brokenLinks`) before reading `errorDoc`.
- `broken-link` and `frontmatter-error` findings have **no** `errorDoc` — `has_error` is `FALSE`. Branch on `data[].type` (or check `attributes.brokenLinks` / `attributes.frontmatterParseError`) before reading `errorDoc`.
- A `frontmatter-error` means the file _did_ index — just without its frontmatter. The body is fine; whatever the YAML declared (skill `commands`, `boxel.kind`) is gone until the YAML is fixed and the realm re-indexes.
- JSON-API resource `id` is `${entryType}::${url}`, not bare `${url}`. To get the URL back, read `attributes.url`.
- On transport failure with `--json`, stdout receives `{"error":"..."}` (not the data envelope) and the process exits non-zero. Successful calls always emit `{ "data": [...] }`. A consumer reading only stdout can distinguish failure from "realm is healthy" by checking for the `error` key (or the exit code).
- The list does not include queue depth, running-job progress, or a "last finished at" timestamp. Those are separate concerns and may be folded into a future `indexing-status` command.
53 changes: 52 additions & 1 deletion packages/boxel-cli/src/commands/realm/indexing-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,25 @@ export interface BrokenLinkEntry {
};
}

export type IndexingErrorsEntry = IndexingErrorEntry | BrokenLinkEntry;
// Resource for a row that indexed cleanly but whose YAML frontmatter wouldn't
// parse, surfaced via `diagnostics.frontmatterParseError`. The file indexes
// body-only; anything the frontmatter declared (e.g. a skill's commands) was
// dropped.
export interface FrontmatterErrorEntry {
type: 'frontmatter-error';
id: string;
attributes: {
url: string;
entryType: string;
diagnostics: Record<string, unknown> | null;
frontmatterParseError: FrontmatterParseErrorLike;
};
}

export type IndexingErrorsEntry =
| IndexingErrorEntry
| BrokenLinkEntry
| FrontmatterErrorEntry;

export interface IndexingErrorsDocument {
data: IndexingErrorsEntry[];
Expand All @@ -54,6 +72,14 @@ export interface BrokenLinkLike {
kind: 'error' | 'not-found';
}

// Mirror of FrontmatterParseError from @cardstack/runtime-common, kept local
// for the same reason.
export interface FrontmatterParseErrorLike {
message: string;
line?: number;
column?: number;
}

export interface IndexingErrorsResult {
ok: boolean;
document?: IndexingErrorsDocument;
Expand Down Expand Up @@ -179,9 +205,34 @@ export function formatEntry(entry: IndexingErrorsEntry): string {
if (entry.type === 'indexing-error') {
return `${prefix} ${url} ${shortErrorMessage(entry.attributes.errorDoc)}`;
}
if (entry.type === 'frontmatter-error') {
return `${prefix} ${url} ${shortFrontmatterError(
entry.attributes.frontmatterParseError,
)}`;
}
return `${prefix} ${url} ${shortBrokenLinks(entry.attributes.brokenLinks)}`;
}

export function shortFrontmatterError(
parseError: FrontmatterParseErrorLike | null | undefined,
): string {
if (!parseError) {
return '<no frontmatter error>';
}
let where =
typeof parseError.line === 'number'
? ` (line ${parseError.line}${
typeof parseError.column === 'number' ? `:${parseError.column}` : ''
})`
: '';
let raw = (parseError.message ?? '<no message>').replace(/\s+/g, ' ').trim();
let message =
raw.length <= SHORT_MESSAGE_MAX
? raw
: `${raw.slice(0, SHORT_MESSAGE_MAX - 1)}…`;
return `frontmatter parse error${where}: ${message}`;
}

const BROKEN_LINKS_MAX_LIST = 3;

export function shortBrokenLinks(
Expand Down
102 changes: 102 additions & 0 deletions packages/boxel-cli/tests/integration/realm-indexing-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {
indexingErrors,
shortErrorMessage,
shortBrokenLinks,
shortFrontmatterError,
formatEntry,
type IndexingErrorEntry,
type BrokenLinkEntry,
type FrontmatterErrorEntry,
} from '../../src/commands/realm/indexing-errors.ts';
import { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
Expand Down Expand Up @@ -192,6 +194,93 @@ describe('realm indexing-errors (integration)', () => {
expect(formatEntry(entry!)).toContain('1 broken: author→');
});

it('surfaces frontmatter-error rows with has_error = FALSE', async () => {
let dbAdapter = getTestDbAdapter();
let fileURL = `${realmUrl}skills/bad/SKILL.md`;
let fileAlias = `${realmUrl}skills/bad/SKILL`;
let frontmatterParseError = {
message: 'Implicit map keys need to be on a single line',
line: 4,
column: 3,
};
let diagnostics = { frontmatterParseError };

await dbAdapter!.execute(
`INSERT INTO boxel_index
(url, file_alias, type, realm_version, realm_url,
has_error, error_doc, diagnostics, is_deleted)
VALUES ($1, $2, 'file', 1, $3,
FALSE, NULL, $4::jsonb, FALSE)`,
{
bind: [fileURL, fileAlias, realmUrl, JSON.stringify(diagnostics)],
},
);

let result = await indexingErrors(realmUrl, { profileManager });
expect(result.ok).toBe(true);
let entry = result.document!.data.find(
(e) => e.attributes.url === fileURL,
) as FrontmatterErrorEntry | undefined;
expect(entry).toBeDefined();
expect(entry!.type).toBe('frontmatter-error');
expect(entry!.attributes.frontmatterParseError).toEqual(
frontmatterParseError,
);
expect(formatEntry(entry!)).toContain('frontmatter parse error (line 4:3)');
});

it('surfaces both findings when a healthy row has broken links AND a frontmatter error', async () => {
let dbAdapter = getTestDbAdapter();
let fileURL = `${realmUrl}skills/both/SKILL.md`;
let fileAlias = `${realmUrl}skills/both/SKILL`;
let frontmatterParseError = {
message: 'Implicit map keys need to be on a single line',
line: 4,
column: 3,
};
let brokenLinks = [
{
fieldName: 'related',
reference: 'https://example.com/missing',
kind: 'not-found',
},
];
let diagnostics = { frontmatterParseError, brokenLinks };

await dbAdapter!.execute(
`INSERT INTO boxel_index
(url, file_alias, type, realm_version, realm_url,
has_error, error_doc, diagnostics, is_deleted)
VALUES ($1, $2, 'file', 1, $3,
FALSE, NULL, $4::jsonb, FALSE)`,
{
bind: [fileURL, fileAlias, realmUrl, JSON.stringify(diagnostics)],
},
);

let result = await indexingErrors(realmUrl, { profileManager });
expect(result.ok).toBe(true);
let forUrl = result.document!.data.filter(
(e) => e.attributes.url === fileURL,
);
expect(forUrl.length).toBe(2);
let byType = Object.fromEntries(forUrl.map((e) => [e.type, e]));

let frontmatterEntry = byType['frontmatter-error'] as FrontmatterErrorEntry;
expect(frontmatterEntry).toBeDefined();
expect(frontmatterEntry.attributes.frontmatterParseError).toEqual(
frontmatterParseError,
);
expect(formatEntry(frontmatterEntry)).toContain(
'frontmatter parse error (line 4:3)',
);

let brokenLinkEntry = byType['broken-link'] as BrokenLinkEntry;
expect(brokenLinkEntry).toBeDefined();
expect(brokenLinkEntry.attributes.brokenLinks).toEqual(brokenLinks);
expect(formatEntry(brokenLinkEntry)).toContain('1 broken: related→');
});

it('returns ok=false when the realm is unreachable', async () => {
let result = await indexingErrors('http://127.0.0.1:1/fake/', {
profileManager,
Expand Down Expand Up @@ -240,4 +329,17 @@ describe('realm indexing-errors (integration)', () => {
]),
).toBe('4 broken: a→x, b→y, c→z, …+1 more');
});

it('shortFrontmatterError renders message with optional position', () => {
expect(shortFrontmatterError(null)).toBe('<no frontmatter error>');
expect(shortFrontmatterError({ message: 'bad yaml' })).toBe(
'frontmatter parse error: bad yaml',
);
expect(
shortFrontmatterError({ message: 'bad yaml', line: 4, column: 3 }),
).toBe('frontmatter parse error (line 4:3): bad yaml');
expect(shortFrontmatterError({ message: 'bad yaml', line: 4 })).toBe(
'frontmatter parse error (line 4): bad yaml',
);
});
});
Loading