Skip to content

Commit 1892725

Browse files
crystianclaude
andcommitted
fix(scan,ui): restore files-rail stale-clock icon via sidecar status
The corpus/render split rebuilt the files rail from the lightweight GET /api/folders payload instead of the sidecar-carrying branch nodes. It threaded errorCount / warnCount for the error/warn badges but dropped the sidecar drift status, so the rail's per-row stale-clock icon went dark in both demo and sm serve mode. Thread a sidecarStatus field end to end: the kernel lite-node loader selects scan_nodes.sidecar_status, the BFF emits it on /api/folders, and the UI projects it onto node.sidecar so the rail flags staleness corpus-wide without hydrating the branch payload. The demo static data source mirrors the same lite shape. spec/cli-contract.md documents the additive field and spec/index.json is regenerated. The e2e sidecar smoke test was stale: folders now render collapsed by default, so it expands the tree (files-expand-all) before asserting the stale icon on the nested leaf row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NF5XoRisDA9Z3fNK2KMoy9
1 parent 92a9e19 commit 1892725

17 files changed

Lines changed: 127 additions & 15 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@skill-map/cli": patch
3+
"@skill-map/spec": patch
4+
---
5+
6+
Restores the files rail's per-row stale-clock icon, dropped when the rail switched to building from the lightweight `GET /api/folders` payload (which carried the error / warn counts but not the sidecar drift status). The endpoint now emits a `sidecarStatus` field (the persisted `scan_nodes.sidecar_status`, `null` when there is no parseable sidecar), threaded from the kernel loader through the BFF into the rail so staleness flags corpus-wide in demo and `sm serve` mode.
7+
8+
## User-facing
9+
10+
The files rail again flags out-of-date nodes with the clock icon, so you can see at a glance which files have drifted since their last review.

e2e/smoke/sidecar.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ test.describe('sidecar UI surface (Step 9.6.5)', () => {
7070
// `stale-both` sidecar, so its row must render the stale clock icon.
7171
await gotoWorkspace(page);
7272

73+
// Folders now render COLLAPSED by default, so a nested leaf row is not
74+
// mounted until its ancestor folders are open. Expand the whole tree
75+
// (the rail's expand-all control) to surface `content-editor.md`.
76+
await page.getByTestId('files-expand-all').click();
77+
7378
const row = page.getByTestId(`files-leaf-${STALE_PATH}`);
7479
await expect(row).toBeVisible();
7580
await expect(row.locator('.files__stale-icon')).toBeVisible();

spec/cli-contract.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -602,7 +602,7 @@ The reference implementation ships a Hono BFF rooted at `src/server/`. One Node
602602
| `GET /api/nodes/:pathB64[?include=body]` | implemented | Single-node detail envelope: `{ schemaVersion, kind: 'node', item: Node, links: { incoming: Link[], outgoing: Link[] }, issues: Issue[] }`. `:pathB64` is base64url (RFC 4648 §5, no padding) of `node.path`. Missing node or malformed `pathB64` → 404 `not-found`. **`?include=body`** (Step 14.5.a), opt-in flag adding `item.body: string \| null` to the response. The body is read from disk on demand at request time (the kernel persists `bodyHash` only). `null` means the source file was missing / unreadable when the request landed (the watcher re-emits `scan.completed` on catch-up). Without the flag, `item.body` is `undefined` and the handler does not touch the filesystem. |
603603
| `GET /api/links?kind=&from=&to=` | implemented | `RestEnvelope` (`kind: 'links'`), list of links. Filters: `kind` (CSV whitelist of `link.kind`), `from` (exact match on `link.source`), `to` (exact match on `link.target`). No pagination at v14.2. |
604604
| `GET /api/issues?severity=&analyzerId=&node=` | implemented | `RestEnvelope` (`kind: 'issues'`), list of issues. Filters: `severity` (CSV from `error\|warn\|info`), `analyzerId` (CSV; qualified or short suffix per `sm check --analyzers`), `node` (filter to issues whose `nodeIds` includes the path). No pagination at v14.2. |
605-
| `GET /api/folders` | implemented | `RestEnvelope` (`kind: 'folders'`), lightweight full-corpus projection: one entry per scanned node `{ path, kind, linksInCount, linksOutCount, tokensTotal, modifiedAtMs, errorCount, warnCount }`. Only cheap scalar columns of `scan_nodes` (no frontmatter, body, links, signals, or contributions), so the SPA folders tree renders the whole corpus (up to `scan.maxScan`) with per-node data columns + per-folder issue badges without hydrating the full `ScanResult`. `tokensTotal` / `modifiedAtMs` are nullable (tokenization disabled / unknown mtime). `errorCount` / `warnCount` are the count of error / warn issues whose `nodeIds` include that path (the same incidence the tree rolls up across descendants). No pagination (the complete tree is the point; the corpus is already bounded by `scan.maxScan`). DB absent → zero items. |
605+
| `GET /api/folders` | implemented | `RestEnvelope` (`kind: 'folders'`), lightweight full-corpus projection: one entry per scanned node `{ path, kind, linksInCount, linksOutCount, tokensTotal, modifiedAtMs, errorCount, warnCount, sidecarStatus }`. Only cheap scalar columns of `scan_nodes` (no frontmatter, body, links, signals, or contributions), so the SPA folders tree renders the whole corpus (up to `scan.maxScan`) with per-node data columns + per-folder issue badges without hydrating the full `ScanResult`. `tokensTotal` / `modifiedAtMs` are nullable (tokenization disabled / unknown mtime). `errorCount` / `warnCount` are the count of error / warn issues whose `nodeIds` include that path (the same incidence the tree rolls up across descendants). `sidecarStatus` is the node's sidecar drift status (`scan_nodes.sidecar_status`), `null` when there is no parseable sidecar, so the folders tree can flag per-row staleness without hydrating the branch payload. No pagination (the complete tree is the point; the corpus is already bounded by `scan.maxScan`). DB absent → zero items. |
606606
| `GET /api/branch?path=<prefix>&path=<prefix>&limit=<n>` | implemented | Branch projection for the map. `path` is **repeatable**: the response is the UNION of the subtrees under every given prefix (forward-slash; a node matches a prefix when its path equals it or starts with `<prefix>/`). No `path` (or a single empty one) = the whole corpus. The union is capped at `limit` nodes (default and effective max = the scan's `maxRenderNodes`), so the response stays bounded regardless of how many prefixes are sent. Direct shape (no envelope wrap, like `/api/scan`): `{ schemaVersion, kind: 'branch', branch: { paths, total, rendered, truncated, cap }, nodes: Node[], links: Link[], issues: Issue[] }`, where `paths` echoes the requested prefixes. `nodes` is the first `rendered` nodes of the union in stable path order; `links` carries only edges whose source AND target are in `nodes`; `issues` carries those touching `nodes`. `truncated` is `total > cap`. Lets the SPA render a multi-folder selection without hydrating the full `ScanResult`. DB absent → empty branch (zero nodes). Validation: `limit` integer ≥ 1 else 400 `bad-query`. |
607607
| `GET /api/graph?format=ascii\|json\|md` | implemented | formatter-rendered graph. `Content-Type` per format: `text/plain` (ascii), `application/json` (json), `text/markdown` (md / mermaid). Default `format=ascii`. Unknown format → 400 `bad-query`. |
608608
| `GET /api/config` | implemented | `RestEnvelope` (`kind: 'config'`), merged effective config (defaults → user → user-local → project → project-local → override). |

spec/index.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@
181181
"CHANGELOG.md": "3c44981379253010e1181af77ae3d5910a5037a013d2a49fba870e8a31338a70",
182182
"README.md": "a790cd010b46d47883d1f37e3893cea9d7aa69ec4750c0202e6a0c99991e7980",
183183
"architecture.md": "5eb1b54e66b3db3d28091830a9a990e94dc72b4488f94ab9506cd509aa884e8b",
184-
"cli-contract.md": "21679f985b15371c05b0b0619ee4b56b2ceb600533c4c81e9e387d5bfbde426e",
184+
"cli-contract.md": "3733b1277c92d2af9e0728b0bcdd0579dd13bf0c3adfd7f272a18efd3a8c8257",
185185
"conformance/README.md": "dcbef7249f161acf597552a05dcadc813cd0ced430dcd3f813fcf5e1c876335d",
186186
"conformance/cases/backtick-path-extraction.json": "4620e7f8bc161fc57cb44001e9d99879c7e22b4865a0c27a20dc28969cd936d9",
187187
"conformance/cases/extractor-collision-detection.json": "179a02c61892f0d26492de0c4e2c327fa6b4986d1265a8f119e871df6afe4658",

src/kernel/adapters/sqlite/__tests__/branch-folders-storage.spec.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Acceptance tests for the lazy-load storage primitives the BFF
33
* `/api/folders` + `/api/branch` endpoints consume:
44
*
5-
* - `port.scans.listLiteNodes()`, `{ path, kind, linksInCount, linksOutCount, tokensTotal, modifiedAtMs }[]` ordered by path.
5+
* - `port.scans.listLiteNodes()`, `{ path, kind, linksInCount, linksOutCount, tokensTotal, modifiedAtMs, sidecarStatus }[]` ordered by path.
66
* - `port.scans.issueCountsByPath()`, per-node error / warn incidence
77
* via `json_each` + GROUP BY (info ignored).
88
* - `port.scans.effectiveMaxRenderNodes()`, the scan's recorded cap
@@ -125,7 +125,7 @@ describe('port.scans.listLiteNodes', () => {
125125
}
126126
});
127127

128-
it('returns { path, kind } ordered by path ASC', async () => {
128+
it('returns { path, kind, sidecarStatus } ordered by path ASC', async () => {
129129
const adapter = new SqliteStorageAdapter({ databasePath: freshDbPath('lite'), autoBackup: false });
130130
await adapter.init();
131131
try {
@@ -140,6 +140,7 @@ describe('port.scans.listLiteNodes', () => {
140140
linksOutCount: 0,
141141
tokensTotal: null,
142142
modifiedAtMs: null,
143+
sidecarStatus: null,
143144
},
144145
{
145146
path: 'skills/zeta.md',
@@ -148,12 +149,45 @@ describe('port.scans.listLiteNodes', () => {
148149
linksOutCount: 0,
149150
tokensTotal: null,
150151
modifiedAtMs: null,
152+
sidecarStatus: null,
151153
},
152154
]);
153155
} finally {
154156
await adapter.close();
155157
}
156158
});
159+
160+
it('carries the persisted sidecar_status when a sidecar is present', async () => {
161+
const adapter = new SqliteStorageAdapter({ databasePath: freshDbPath('lite-sidecar'), autoBackup: false });
162+
await adapter.init();
163+
try {
164+
await adapter.db
165+
.insertInto('scan_nodes')
166+
.values({
167+
path: 'docs/drift.md',
168+
kind: 'note',
169+
provider: 'claude',
170+
frontmatterJson: '{}',
171+
bodyHash: HASH,
172+
frontmatterHash: HASH,
173+
bytesFrontmatter: 0,
174+
bytesBody: 0,
175+
bytesTotal: 0,
176+
tokensFrontmatter: null,
177+
tokensBody: null,
178+
tokensTotal: null,
179+
scannedAt: Date.now(),
180+
sidecarPresent: 1,
181+
sidecarStatus: 'stale-body',
182+
})
183+
.execute();
184+
const rows = await adapter.scans.listLiteNodes();
185+
assert.equal(rows.length, 1);
186+
assert.equal(rows[0]?.sidecarStatus, 'stale-body');
187+
} finally {
188+
await adapter.close();
189+
}
190+
});
157191
});
158192

159193
describe('port.scans.issueCountsByPath', () => {

src/kernel/adapters/sqlite/scan-load.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ export async function loadLiteNodes(
286286
'linksOutCount',
287287
'tokensTotal',
288288
'modifiedAtMs',
289+
'sidecarStatus',
289290
])
290291
.orderBy('path', 'asc')
291292
.execute();
@@ -296,6 +297,7 @@ export async function loadLiteNodes(
296297
linksOutCount: r.linksOutCount,
297298
tokensTotal: r.tokensTotal,
298299
modifiedAtMs: r.modifiedAtMs,
300+
sidecarStatus: r.sidecarStatus,
299301
}));
300302
}
301303

src/kernel/types/storage.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ export interface ILiteNode {
8181
linksOutCount: number;
8282
tokensTotal: number | null;
8383
modifiedAtMs: number | null;
84+
/**
85+
* The persisted `scan_nodes.sidecar_status`, null when there is no
86+
* parseable sidecar. Lets the folders rail flag staleness corpus-wide,
87+
* sibling of the issue counts.
88+
*/
89+
sidecarStatus: string | null;
8490
}
8591

8692
/**

src/server/routes/__tests__/folders-endpoint.spec.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
*
44
* Boots a real `createServer()` against a primed-DB tempdir, fires
55
* `fetch()`, and asserts on the `RestEnvelope` (`kind: 'folders'`) shape:
6-
* one item per scanned node `{ path, kind, errorCount, warnCount }`, the
7-
* error / warn incidence rolled up per node from `scan_issues`.
6+
* one item per scanned node `{ path, kind, errorCount, warnCount,
7+
* sidecarStatus }`, the error / warn incidence rolled up per node from
8+
* `scan_issues` and the sidecar drift status carried straight from the
9+
* lite node.
810
*
911
* Coverage:
1012
* - empty / absent DB → zero items.
11-
* - one item per node with `{ path, kind }`.
13+
* - one item per node with `{ path, kind, sidecarStatus }`.
1214
* - errorCount / warnCount roll up issue incidence; info ignored.
1315
* - no pagination (counts.total == items.length, no counts.page).
1416
*
@@ -24,7 +26,7 @@ import { after, before, beforeEach, describe, it } from 'node:test';
2426

2527
import { SqliteStorageAdapter } from '../../../kernel/adapters/sqlite/index.js';
2628
import { persistScanResult } from '../../../kernel/adapters/sqlite/scan-persistence.js';
27-
import type { Issue, Node, ScanResult } from '../../../kernel/types.js';
29+
import type { Issue, Node, ScanResult, SidecarStatus } from '../../../kernel/types.js';
2830
import {
2931
createServer,
3032
type IServerOptions,
@@ -54,7 +56,7 @@ beforeEach(() => {
5456
rmSync(root.dbPath, { force: true });
5557
});
5658

57-
function makeNode(path: string, kind = 'note'): Node {
59+
function makeNode(path: string, kind = 'note', sidecarStatus?: SidecarStatus): Node {
5860
return {
5961
path,
6062
kind,
@@ -65,6 +67,7 @@ function makeNode(path: string, kind = 'note'): Node {
6567
linksOutCount: 0,
6668
linksInCount: 0,
6769
externalRefsCount: 0,
70+
...(sidecarStatus ? { sidecar: { present: true, status: sidecarStatus } } : {}),
6871
};
6972
}
7073

@@ -132,7 +135,13 @@ function url(handle: IServerHandle, path: string): string {
132135
interface IFoldersBody {
133136
schemaVersion: string;
134137
kind: string;
135-
items: Array<{ path: string; kind: string; errorCount: number; warnCount: number }>;
138+
items: Array<{
139+
path: string;
140+
kind: string;
141+
errorCount: number;
142+
warnCount: number;
143+
sidecarStatus: string | null;
144+
}>;
136145
counts: { total: number; returned: number; page?: unknown };
137146
}
138147

@@ -150,7 +159,12 @@ describe('GET /api/folders', () => {
150159
});
151160

152161
it('one item per node with { path, kind }, ordered by path', async () => {
153-
await prime([makeNode('skills/zeta.md', 'skill'), makeNode('agents/alpha.md', 'agent')], []);
162+
await prime(
163+
// alpha carries a non-fresh sidecar, zeta has none, so the lite
164+
// payload exercises both the non-null and null sidecarStatus paths.
165+
[makeNode('skills/zeta.md', 'skill'), makeNode('agents/alpha.md', 'agent', 'stale-body')],
166+
[],
167+
);
154168
await bootAndUse(async (handle) => {
155169
const res = await fetch(url(handle, '/api/folders'));
156170
assert.equal(res.status, 200);
@@ -163,6 +177,11 @@ describe('GET /api/folders', () => {
163177
{ path: 'skills/zeta.md', kind: 'skill' },
164178
],
165179
);
180+
// sidecarStatus rides the lite payload: the node with a parseable
181+
// sidecar carries its drift status, the plain node carries null.
182+
const byPath = new Map(body.items.map((i) => [i.path, i]));
183+
assert.equal(byPath.get('agents/alpha.md')!.sidecarStatus, 'stale-body');
184+
assert.equal(byPath.get('skills/zeta.md')!.sidecarStatus, null);
166185
// Only the cheap scalar columns: no frontmatter / body / signals /
167186
// contributions leaked onto the item.
168187
assert.deepEqual(Object.keys(body.items[0]!).sort(), [
@@ -172,6 +191,7 @@ describe('GET /api/folders', () => {
172191
'linksOutCount',
173192
'modifiedAtMs',
174193
'path',
194+
'sidecarStatus',
175195
'tokensTotal',
176196
'warnCount',
177197
]);

src/server/routes/folders.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
* `GET /api/folders`, lightweight full-corpus projection.
33
*
44
* One item per scanned node `{ path, kind, linksInCount, linksOutCount,
5-
* tokensTotal, modifiedAtMs, errorCount, warnCount }`, only cheap scalar
6-
* columns of `scan_nodes` (no frontmatter / body / links / signals /
7-
* contributions) and no pagination. Feeds the SPA
5+
* tokensTotal, modifiedAtMs, errorCount, warnCount, sidecarStatus }`,
6+
* only cheap scalar columns of `scan_nodes` (no frontmatter / body /
7+
* links / signals / contributions) and no pagination. Feeds the SPA
88
* folders tree so it renders the WHOLE corpus (up to `scan.maxScan`)
99
* with per-folder issue badges without hydrating the full `ScanResult`
1010
* (the graph map lazy-loads its branch via `/api/branch`).
@@ -16,6 +16,11 @@
1616
* never by loading every issue into memory. The `info` severity is
1717
* intentionally ignored (the tree badges only error / warn).
1818
*
19+
* `sidecarStatus` is the node's sidecar drift status
20+
* (`scan_nodes.sidecar_status`), `null` when there is no parseable
21+
* sidecar, so the folders rail can flag per-row staleness without
22+
* hydrating the branch payload.
23+
*
1924
* Response is the canonical list envelope (`kind: 'folders'`), mirroring
2025
* `/api/nodes` / `/api/links`. No pagination: the complete tree is the
2126
* point and the corpus is already bounded by `scan.maxScan`, so
@@ -40,6 +45,7 @@ interface IFolderItem {
4045
modifiedAtMs: number | null;
4146
errorCount: number;
4247
warnCount: number;
48+
sidecarStatus: string | null;
4349
}
4450

4551
export function registerFoldersRoute(app: Hono, deps: IRouteDeps): void {
@@ -67,6 +73,7 @@ export function registerFoldersRoute(app: Hono, deps: IRouteDeps): void {
6773
modifiedAtMs: n.modifiedAtMs,
6874
errorCount: counts?.error ?? 0,
6975
warnCount: counts?.warn ?? 0,
76+
sidecarStatus: n.sidecarStatus,
7077
};
7178
});
7279

ui/src/app/views/files-view/__tests__/files-view.interactions.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ function makeLoaderStub(
8282
modifiedAtMs: null,
8383
errorCount: counts[n.path]?.errorCount ?? 0,
8484
warnCount: counts[n.path]?.warnCount ?? 0,
85+
sidecarStatus: null,
8586
}));
8687
return {
8788
nodes: signal(nodes),

0 commit comments

Comments
 (0)