mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 17:02:19 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
980de2a424 | ||
|
|
a3864272a9 | ||
|
|
efb73858dd | ||
|
|
393369090b | ||
|
|
ed1563983f |
@@ -354,6 +354,40 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
|
||||
if (cli.forceAll) return; // both surfaces flushed
|
||||
}
|
||||
|
||||
const completed = loadCompletedMigrations();
|
||||
const idx = indexCompleted(completed);
|
||||
const plan = buildPlan(idx, installed, cli.specificMigration);
|
||||
|
||||
// Bug 3 — surface wedged migrations as a loud, actionable error.
|
||||
if (plan.wedged.length > 0) {
|
||||
for (const m of plan.wedged) {
|
||||
console.error(
|
||||
`\nMigration v${m.version} is WEDGED (${MAX_CONSECUTIVE_PARTIALS}+ consecutive partials with no completion). ` +
|
||||
`Check ~/.gbrain/upgrade-errors.jsonl for the last failure reasons, fix the underlying issue, then run:\n` +
|
||||
` gbrain apply-migrations --force-retry ${m.version}\n` +
|
||||
`Then re-run \`gbrain apply-migrations --yes\`.`,
|
||||
);
|
||||
}
|
||||
// Don't exit — applied/partial/pending are still worth reporting and running.
|
||||
}
|
||||
|
||||
if (cli.specificMigration && plan.applied.length + plan.partial.length + plan.pending.length + plan.skippedFuture.length === 0) {
|
||||
console.error(`No migration registered with version "${cli.specificMigration}". Run \`gbrain apply-migrations --list\` to see registered versions.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Read-only branches: --list and --dry-run print plan/state without
|
||||
// touching anything. Skip the schema-drift pre-flight (which opens a DB
|
||||
// connection) — its only purpose is to warn before APPLYING; for
|
||||
// informational commands the connect+disconnect cycle is both wasted
|
||||
// work and a portability hazard. On Windows the PGLite WASM teardown
|
||||
// leaks worker handles in execFileSync contexts, surfacing as a spurious
|
||||
// exit-1 to parent processes that shell out to `apply-migrations --list`
|
||||
// (e.g. `skillpack-check`). The PGLite-specific skip below is preserved
|
||||
// for non-read-only paths.
|
||||
if (cli.list) { printList(plan, installed); process.exit(0); }
|
||||
if (cli.dryRun) { printDryRun(plan, installed); process.exit(0); }
|
||||
|
||||
// Pre-flight: warn if schema migrations (migrate.ts) are behind.
|
||||
// apply-migrations runs orchestrator migrations only; schema migrations
|
||||
// run via connectEngine() / initSchema(). Users often expect this CLI
|
||||
@@ -392,31 +426,6 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
|
||||
// run their filesystem-only phases.
|
||||
}
|
||||
|
||||
const completed = loadCompletedMigrations();
|
||||
const idx = indexCompleted(completed);
|
||||
const plan = buildPlan(idx, installed, cli.specificMigration);
|
||||
|
||||
// Bug 3 — surface wedged migrations as a loud, actionable error.
|
||||
if (plan.wedged.length > 0) {
|
||||
for (const m of plan.wedged) {
|
||||
console.error(
|
||||
`\nMigration v${m.version} is WEDGED (${MAX_CONSECUTIVE_PARTIALS}+ consecutive partials with no completion). ` +
|
||||
`Check ~/.gbrain/upgrade-errors.jsonl for the last failure reasons, fix the underlying issue, then run:\n` +
|
||||
` gbrain apply-migrations --force-retry ${m.version}\n` +
|
||||
`Then re-run \`gbrain apply-migrations --yes\`.`,
|
||||
);
|
||||
}
|
||||
// Don't exit — applied/partial/pending are still worth reporting and running.
|
||||
}
|
||||
|
||||
if (cli.specificMigration && plan.applied.length + plan.partial.length + plan.pending.length + plan.skippedFuture.length === 0) {
|
||||
console.error(`No migration registered with version "${cli.specificMigration}". Run \`gbrain apply-migrations --list\` to see registered versions.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (cli.list) { printList(plan, installed); process.exit(0); }
|
||||
if (cli.dryRun) { printDryRun(plan, installed); process.exit(0); }
|
||||
|
||||
const toRun: Migration[] = [...plan.partial, ...plan.pending];
|
||||
if (toRun.length === 0) {
|
||||
console.log('All migrations up to date.');
|
||||
|
||||
@@ -474,7 +474,11 @@ export function extractTimelineFromContent(content: string, slug: string): Extra
|
||||
const entries: ExtractedTimelineEntry[] = [];
|
||||
|
||||
// Format 1: Bullet — - **YYYY-MM-DD** | Source — Summary
|
||||
const bulletPattern = /^-\s+\*\*(\d{4}-\d{2}-\d{2})\*\*\s*\|\s*(.+?)\s*[—–-]\s*(.+)$/gm;
|
||||
// The separator is an em/en dash (optionally spaced, as before) OR a plain
|
||||
// hyphen that MUST be surrounded by whitespace. A bare `-` with `\s*` on
|
||||
// both sides split hyphenated slugs mid-word (`acme-consulting-group` →
|
||||
// source "acme"), mangling timelines on every extract pass (#1341).
|
||||
const bulletPattern = /^-\s+\*\*(\d{4}-\d{2}-\d{2})\*\*\s*\|\s*(.+?)(?:\s*[—–]\s*|\s+-\s+)(.+)$/gm;
|
||||
let match;
|
||||
while ((match = bulletPattern.exec(content)) !== null) {
|
||||
entries.push({ slug, date: match[1], source: match[2].trim(), summary: match[3].trim() });
|
||||
|
||||
+51
-5
@@ -68,6 +68,49 @@ const LLM_PREAMBLES = [
|
||||
/^Absolutely\.?\s*Here[^.\n]*\.?\s*\n*/gim,
|
||||
];
|
||||
|
||||
// ── Whole-page code-fence wrap (LLM artifact) ───────────────────────
|
||||
//
|
||||
// An LLM sometimes returns an ENTIRE page wrapped in a ```markdown fence.
|
||||
// Strip that wrap — but ONLY when the whole body is wrapped, never when a note
|
||||
// merely *contains* a ```markdown block mid-document (e.g. a Notion export that
|
||||
// fences a config snippet). The earlier code detected the wrap with /m regexes
|
||||
// (a fence anywhere) but stripped the closing fence with a string-anchored
|
||||
// regex, so a mid-document block lost only its trailing ``` and was left with
|
||||
// an open fence — re-corrupted on every autopilot lint cycle. Detection and fix
|
||||
// now share this single check so they can't drift apart again.
|
||||
|
||||
function frontmatterLength(content: string): number {
|
||||
const m = /^---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/.exec(content);
|
||||
return m ? m[0].length : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Treat a page as fence-wrapped only when the entire body (after any YAML
|
||||
* frontmatter) is a single ```markdown fence: the first non-blank body line
|
||||
* opens it, the last non-blank body line closes it, and no bare ``` appears
|
||||
* between them (which would mean the fence closed early — not a whole-page
|
||||
* wrap). Returns the unwrapped content (frontmatter preserved) when wrapped.
|
||||
*/
|
||||
function analyzeMarkdownWrap(content: string): { wrapped: boolean; unwrapped: string } {
|
||||
const fmLen = frontmatterLength(content);
|
||||
const head = content.slice(0, fmLen);
|
||||
const lines = content.slice(fmLen).split('\n');
|
||||
|
||||
let first = 0;
|
||||
while (first < lines.length && lines[first].trim() === '') first++;
|
||||
let last = lines.length - 1;
|
||||
while (last >= 0 && lines[last].trim() === '') last--;
|
||||
|
||||
if (first >= last) return { wrapped: false, unwrapped: content };
|
||||
if (!/^```(?:markdown|md)\s*$/.test(lines[first].trim())) return { wrapped: false, unwrapped: content };
|
||||
if (lines[last].trim() !== '```') return { wrapped: false, unwrapped: content };
|
||||
for (let i = first + 1; i < last; i++) {
|
||||
if (lines[i].trim().startsWith('```')) return { wrapped: false, unwrapped: content };
|
||||
}
|
||||
|
||||
return { wrapped: true, unwrapped: head + lines.slice(first + 1, last).join('\n') };
|
||||
}
|
||||
|
||||
// ── Rules ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -126,8 +169,10 @@ export function lintContent(content: string, filePath: string, opts: LintContent
|
||||
}
|
||||
}
|
||||
|
||||
// Rule: Wrapping code fences (```markdown ... ```)
|
||||
if (content.match(/^```(?:markdown|md)\s*\n/m) && content.match(/\n```\s*$/m)) {
|
||||
// Rule: Wrapping code fences — the WHOLE page body wrapped in ```markdown …
|
||||
// ``` (an LLM artifact). A ```markdown block mid-document is legitimate and
|
||||
// must NOT trigger this (see analyzeMarkdownWrap).
|
||||
if (analyzeMarkdownWrap(content).wrapped) {
|
||||
issues.push({
|
||||
file: filePath, line: 1, rule: 'code-fence-wrap',
|
||||
message: 'Page wrapped in ```markdown code fences (LLM artifact)',
|
||||
@@ -291,9 +336,10 @@ export function fixContent(content: string): string {
|
||||
fixed = fixed.replace(pattern, '');
|
||||
}
|
||||
|
||||
// Fix wrapping code fences
|
||||
fixed = fixed.replace(/^```(?:markdown|md)\s*\n/, '');
|
||||
fixed = fixed.replace(/\n```\s*$/, '');
|
||||
// Fix wrapping code fences — only a genuine whole-page wrap (see
|
||||
// analyzeMarkdownWrap); never strip the closing fence of a mid-note block.
|
||||
const wrap = analyzeMarkdownWrap(fixed);
|
||||
if (wrap.wrapped) fixed = wrap.unwrapped;
|
||||
|
||||
// Clean up excessive blank lines left by fixes
|
||||
fixed = fixed.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
@@ -29,6 +29,7 @@ import { runMigrateFence } from '../core/skillpack/migrate-fence.ts';
|
||||
import { runScrubLegacy } from '../core/skillpack/scrub-legacy.ts';
|
||||
import { runHarvest, HarvestError } from '../core/skillpack/harvest.ts';
|
||||
import { autoDetectSkillsDir } from '../core/repo-root.ts';
|
||||
import { extractFrontmatterBlock } from '../core/markdown.ts';
|
||||
import {
|
||||
RemoteSourceError,
|
||||
classifySpec,
|
||||
@@ -222,9 +223,9 @@ async function cmdList(args: string[]): Promise<void> {
|
||||
let description: string | null = null;
|
||||
if (existsSync(skillMd)) {
|
||||
const body = readFileSync(skillMd, 'utf-8');
|
||||
const fm = body.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (fm) {
|
||||
const descMatch = fm[1].match(/^description:\s*["']?([^\n"']+)/m);
|
||||
const fm = extractFrontmatterBlock(body);
|
||||
if (fm !== null) {
|
||||
const descMatch = fm.match(/^description:\s*["']?([^\n"']+)/m);
|
||||
if (descMatch) description = descMatch[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
+33
-11
@@ -1245,22 +1245,31 @@ async function writeSyncAnchor(
|
||||
// git-intrinsic committer time of the HEAD we just synced). `undefined` keeps
|
||||
// the legacy 2-column write; `null` clears the column (git unavailable).
|
||||
newestContentEpochMs?: number | null,
|
||||
// #1430: when the upstream pull was attempted and FAILED this run, advance
|
||||
// last_commit (the local import still converged) but do NOT stamp
|
||||
// last_sync_at — doctor's sync_freshness must not read the source as
|
||||
// "fresh" when we never observed remote state (network partition, revoked
|
||||
// credentials, diverged remote). Operator-skipped offline modes (--no-pull,
|
||||
// detached HEAD, no origin) are NOT failures and pass false.
|
||||
pullFailed = false,
|
||||
): Promise<void> {
|
||||
if (sourceId) {
|
||||
const col = which === 'repo_path' ? 'local_path' : 'last_commit';
|
||||
// last_sync_at bookmarked on every last_commit advance.
|
||||
// last_sync_at bookmarked on every last_commit advance — unless the pull
|
||||
// failed this run (#1430).
|
||||
if (which === 'last_commit') {
|
||||
const syncAt = pullFailed ? '' : ', last_sync_at = now()';
|
||||
if (newestContentEpochMs !== undefined) {
|
||||
const iso = newestContentEpochMs === null
|
||||
? null
|
||||
: new Date(newestContentEpochMs).toISOString();
|
||||
await engine.executeRaw(
|
||||
`UPDATE sources SET last_commit = $1, last_sync_at = now(), newest_content_at = $3 WHERE id = $2`,
|
||||
`UPDATE sources SET last_commit = $1${syncAt}, newest_content_at = $3 WHERE id = $2`,
|
||||
[value, sourceId, iso],
|
||||
);
|
||||
} else {
|
||||
await engine.executeRaw(
|
||||
`UPDATE sources SET last_commit = $1, last_sync_at = now() WHERE id = $2`,
|
||||
`UPDATE sources SET last_commit = $1${syncAt} WHERE id = $2`,
|
||||
[value, sourceId],
|
||||
);
|
||||
}
|
||||
@@ -1948,6 +1957,12 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
});
|
||||
}
|
||||
|
||||
// #1430: distinguishes a true pull FAILURE (network/credentials/diverged)
|
||||
// from operator-skipped offline modes. Only a real failure suppresses the
|
||||
// last_sync_at freshness stamp on the anchor writes below; the pull_timeout
|
||||
// path early-returns a partial before any anchor write.
|
||||
let pullAttemptedAndFailed = false;
|
||||
|
||||
if (!opts.noPull && !detachedHead && originRemotePresent) {
|
||||
const _t0 = Date.now();
|
||||
serr(`[gbrain phase] sync.git_pull start`);
|
||||
@@ -1995,6 +2010,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
} else {
|
||||
serr(`Warning: git pull failed: ${msg.slice(0, 100)}`);
|
||||
}
|
||||
pullAttemptedAndFailed = true; // #1430: suppress last_sync_at below
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2077,7 +2093,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// back to the authoritative full reconcile (which now also purges stale
|
||||
// pages for deleted files; see performFullSync's delete-reconcile pass).
|
||||
serr(`Sync anchor ${lastCommit.slice(0, 8)} object missing (gc'd after history rewrite). Running full reimport.`);
|
||||
return performFullSync(engine, fullSyncRoots, headCommit, opts);
|
||||
return performFullSync(engine, fullSyncRoots, headCommit, opts, pullAttemptedAndFailed);
|
||||
}
|
||||
|
||||
// Observability only — NOT control flow. A non-ancestor bookmark is still
|
||||
@@ -2100,7 +2116,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
|
||||
// First sync
|
||||
if (!lastCommit) {
|
||||
return performFullSync(engine, fullSyncRoots, headCommit, opts);
|
||||
return performFullSync(engine, fullSyncRoots, headCommit, opts, pullAttemptedAndFailed);
|
||||
}
|
||||
|
||||
// v0.42.x (#1794): resumable incremental sync — resolve the PINNED target.
|
||||
@@ -2170,7 +2186,10 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// reads it), separate from the import-converged bookmark. Without this,
|
||||
// a cron-driven `*/15 sync` over a quiet vault leaves last_sync_at pinned
|
||||
// to the last real commit, so doctor falsely flags the source as stale.
|
||||
if (opts.sourceId) {
|
||||
// #1430: suppress the heartbeat when the pull was attempted and FAILED —
|
||||
// we never observed remote state, so stamping freshness would mask the
|
||||
// failure. `!opts.dryRun`: a preview must never write.
|
||||
if (opts.sourceId && !pullAttemptedAndFailed && !opts.dryRun) {
|
||||
await engine.executeRaw(
|
||||
`UPDATE sources SET last_sync_at = now() WHERE id = $1`,
|
||||
[opts.sourceId],
|
||||
@@ -2192,7 +2211,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
`[sync] chunker_version gate: stored=${storedVersion ?? 'unset'}, current=${currentVersion}. ` +
|
||||
`Forcing full re-chunk pass (git HEAD unchanged but pipeline version advanced).`,
|
||||
);
|
||||
const result = await performFullSync(engine, fullSyncRoots, headCommit, opts);
|
||||
const result = await performFullSync(engine, fullSyncRoots, headCommit, opts, pullAttemptedAndFailed);
|
||||
await writeChunkerVersion(engine, opts.sourceId, currentVersion);
|
||||
return result;
|
||||
}
|
||||
@@ -2221,7 +2240,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
`[sync] delta ${lastCommit.slice(0, 8)}..${pin.slice(0, 8)} unavailable ` +
|
||||
`(${delta.reason}) — falling back to full reconcile.`,
|
||||
);
|
||||
return performFullSync(engine, fullSyncRoots, headCommit, opts);
|
||||
return performFullSync(engine, fullSyncRoots, headCommit, opts, pullAttemptedAndFailed);
|
||||
}
|
||||
const manifest = delta.manifest;
|
||||
|
||||
@@ -2352,7 +2371,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// (#1794): advance to the PINNED target, and clear any checkpoint (a resume
|
||||
// whose remaining range turned out to have no syncable changes still
|
||||
// completes cleanly here).
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', pin, commitTimeMs(gitContextRoot, pin));
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', pin, commitTimeMs(gitContextRoot, pin), pullAttemptedAndFailed);
|
||||
await engine.setConfig('sync.last_run', new Date().toISOString());
|
||||
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
|
||||
await clearOpCheckpoint(engine, ckpt.paths);
|
||||
@@ -3175,7 +3194,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// "fresh". The checkpoint rows clear here — CONVERGENCE CONTRACT: sync
|
||||
// convergence == IMPORT convergence; downstream extract/facts/embed is
|
||||
// decoupled (its own resumable stale sweeps).
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', pin, commitTimeMs(gitContextRoot, pin));
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', pin, commitTimeMs(gitContextRoot, pin), pullAttemptedAndFailed);
|
||||
await engine.setConfig('sync.last_run', new Date().toISOString());
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'repo_path', anchorPath);
|
||||
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
|
||||
@@ -3413,6 +3432,9 @@ async function performFullSync(
|
||||
roots: { gitContextRoot: string; syncScopeRoot: string; anchorPath: string },
|
||||
headCommit: string,
|
||||
opts: SyncOpts,
|
||||
// #1430: a full reimport triggered after a failed pull still must not mark
|
||||
// the source "fresh" — threaded into the last_commit anchor write.
|
||||
pullFailed = false,
|
||||
): Promise<SyncResult> {
|
||||
const { gitContextRoot, syncScopeRoot, anchorPath } = roots;
|
||||
// Scoped sync → slugs/source_path are git-root-relative (matches the
|
||||
@@ -3500,7 +3522,7 @@ async function performFullSync(
|
||||
const advanceFull = async (): Promise<void> => {
|
||||
// Persist sync state so the next sync is incremental. Routed through
|
||||
// writeSyncAnchor so --source pins the right sources row.
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', headCommit, newestCommitMs(gitContextRoot));
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', headCommit, newestCommitMs(gitContextRoot), pullFailed);
|
||||
await engine.setConfig('sync.last_run', new Date().toISOString());
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'repo_path', anchorPath);
|
||||
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { readFileSync, existsSync, readdirSync } from 'fs';
|
||||
import { join, relative } from 'path';
|
||||
import { extractFrontmatterBlock } from './markdown.ts';
|
||||
import { findResolverFile, findAllResolverFiles, RESOLVER_FILENAMES_LABEL } from './resolver-filenames.ts';
|
||||
import { loadOrDeriveManifest } from './skill-manifest.ts';
|
||||
import {
|
||||
@@ -219,9 +220,8 @@ export function parseResolverEntries(resolverContent: string): ResolverEntry[] {
|
||||
|
||||
/** Simple YAML frontmatter parser — extracts triggers array if present. */
|
||||
function extractTriggers(skillContent: string): string[] {
|
||||
const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!fmMatch) return [];
|
||||
const fm = fmMatch[1];
|
||||
const fm = extractFrontmatterBlock(skillContent);
|
||||
if (fm === null) return [];
|
||||
const triggersMatch = fm.match(/^triggers:\s*\n((?:\s+-\s+.+\n?)*)/m);
|
||||
if (!triggersMatch) return [];
|
||||
return triggersMatch[1]
|
||||
|
||||
+4
-1
@@ -1096,7 +1096,10 @@ export function configDir(): string {
|
||||
}
|
||||
return join(trimmed, '.gbrain');
|
||||
}
|
||||
return join(homedir(), '.gbrain');
|
||||
// Prefer $HOME over os.homedir(): Bun caches homedir()'s initial value and
|
||||
// ignores later process.env.HOME mutations, which breaks test isolation and
|
||||
// any workflow that runs against a specific $HOME (CI, scripted installs).
|
||||
return join(process.env.HOME || homedir(), '.gbrain');
|
||||
}
|
||||
|
||||
export function configPath(): string {
|
||||
|
||||
@@ -3,6 +3,24 @@ import { safeLoad as yamlSafeLoad } from 'js-yaml';
|
||||
import type { Page, PageType } from './types.ts';
|
||||
import { slugifyPath } from './sync.ts';
|
||||
|
||||
/**
|
||||
* Extract the raw YAML frontmatter body between leading `---` fences.
|
||||
*
|
||||
* Returns the body string (LF-normalized) or `null` when no frontmatter is
|
||||
* present. Tolerant of CRLF line endings — input is normalized before
|
||||
* matching so the same call works on files authored on Windows.
|
||||
*
|
||||
* Use this when you only need to run a few targeted regexes against the
|
||||
* frontmatter (e.g. extracting `name:` or `triggers:`); prefer
|
||||
* `parseMarkdown` when you need the full body + timeline split + gray-matter
|
||||
* YAML parsing, and `parseSkillFrontmatter` for SKILL.md-specific fields.
|
||||
*/
|
||||
export function extractFrontmatterBlock(content: string): string | null {
|
||||
const normalized = content.replace(/\r\n/g, '\n');
|
||||
const match = normalized.match(/^---\n([\s\S]*?)\n---/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export type ParseValidationCode =
|
||||
| 'MISSING_OPEN'
|
||||
| 'MISSING_CLOSE'
|
||||
|
||||
+43
-32
@@ -9,40 +9,22 @@
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, renameSync, chmodSync, mkdtempSync, rmSync, existsSync, mkdirSync, appendFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
function home(): string {
|
||||
// `os.homedir()` in Bun caches its initial value and ignores later
|
||||
// `process.env.HOME` mutations, which breaks test isolation and any
|
||||
// workflow that needs to run against a specific $HOME (CI, scripted installs).
|
||||
// Prefer the env var; fall back to the cached OS value. Matches the existing
|
||||
// `src/commands/upgrade.ts` pattern.
|
||||
//
|
||||
// NOTE: prefsDir() and migrationsDir() route through gbrainPath() (which
|
||||
// honors GBRAIN_HOME), so this fallback is only used by code paths that
|
||||
// want $HOME directly (none in this file as of v0.30.3).
|
||||
return process.env.HOME || homedir();
|
||||
}
|
||||
import { join, dirname } from 'path';
|
||||
import { configDir } from './config.ts';
|
||||
|
||||
/**
|
||||
* GBRAIN_HOME-aware override for the .gbrain directory. When the env var
|
||||
* is set, this returns it directly (so the directory is GBRAIN_HOME itself,
|
||||
* matching the convention `src/core/config.ts:gbrainPath` enforces).
|
||||
* When unset, falls back to `<home>/.gbrain` so legacy callers and the
|
||||
* doctor's filesystem-only checks keep working.
|
||||
*
|
||||
* Without this, `~/.gbrain/migrations/completed.jsonl` is the only path
|
||||
* doctor reads on filesystem checks — the test isolation contract that
|
||||
* `gbrainPath()` provides for everywhere else doesn't extend here.
|
||||
* GBRAIN_HOME-aware override for the .gbrain directory. Delegates to
|
||||
* `configDir()` so the GBRAIN_HOME contract is defined in exactly one
|
||||
* place — pre-this-fix, this helper diverged from `configDir()` by
|
||||
* returning `GBRAIN_HOME` directly instead of `GBRAIN_HOME/.gbrain`,
|
||||
* which meant `~/.gbrain/migrations/completed.jsonl` (read by doctor)
|
||||
* and `~/.gbrain/config.json` (read by loadConfig) lived in different
|
||||
* directories whenever a test or operator set GBRAIN_HOME. The
|
||||
* documented convention is "GBRAIN_HOME is a parent dir; we append
|
||||
* `.gbrain`" — see configDir() docstring in src/core/config.ts.
|
||||
*/
|
||||
function gbrainDir(): string {
|
||||
const override = process.env.GBRAIN_HOME;
|
||||
if (override) {
|
||||
const trimmed = override.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return join(home(), '.gbrain');
|
||||
return configDir();
|
||||
}
|
||||
|
||||
export type MinionMode = 'always' | 'pain_triggered' | 'off';
|
||||
@@ -84,9 +66,38 @@ const VALID_MODES: ReadonlyArray<MinionMode> = ['always', 'pain_triggered', 'off
|
||||
// `$HOME/.gbrain` directly, which leaked the developer's local migration
|
||||
// ledger into E2E tests and CI runs even when GBRAIN_HOME was set.
|
||||
function prefsDir(): string { return gbrainDir(); }
|
||||
function prefsPath(): string { return join(prefsDir(), 'preferences.json'); }
|
||||
function prefsPath(): string {
|
||||
const p = join(prefsDir(), 'preferences.json');
|
||||
adoptLegacyFile(p, 'preferences.json');
|
||||
return p;
|
||||
}
|
||||
function migrationsDir(): string { return join(gbrainDir(), 'migrations'); }
|
||||
function completedJsonlPath(): string { return join(migrationsDir(), 'completed.jsonl'); }
|
||||
function completedJsonlPath(): string {
|
||||
const p = join(migrationsDir(), 'completed.jsonl');
|
||||
adoptLegacyFile(p, 'migrations', 'completed.jsonl');
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time legacy-path adoption. Before the gbrainDir()→configDir()
|
||||
* unification, this module wrote preferences.json + migrations/completed.jsonl
|
||||
* to `$GBRAIN_HOME/...` directly (no `.gbrain` suffix) whenever GBRAIN_HOME
|
||||
* was set. Silently switching the read path would orphan an existing
|
||||
* preferences file and — worse — an existing migration ledger, making every
|
||||
* completed migration look pending again. So on first access after upgrade,
|
||||
* move the legacy file into the new location. Best-effort: a failed move
|
||||
* falls back to a fresh file (the pre-fix behavior for the new path).
|
||||
*/
|
||||
function adoptLegacyFile(newPath: string, ...legacySegments: string[]): void {
|
||||
const override = process.env.GBRAIN_HOME?.trim();
|
||||
if (!override) return; // GBRAIN_HOME unset → legacy and new paths were identical
|
||||
const legacyPath = join(override, ...legacySegments);
|
||||
if (legacyPath === newPath || existsSync(newPath) || !existsSync(legacyPath)) return;
|
||||
try {
|
||||
mkdirSync(dirname(newPath), { recursive: true });
|
||||
renameSync(legacyPath, newPath);
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
/** Validate that a value is a recognized minion mode. Throws with the allowed list. */
|
||||
export function validateMinionMode(value: unknown): asserts value is MinionMode {
|
||||
|
||||
@@ -82,7 +82,13 @@ export interface ParsedFrontmatter {
|
||||
* `readFileSync(path, 'utf-8')` at the boundary.
|
||||
*/
|
||||
export function parseSkillFrontmatter(content: string): ParsedFrontmatter | null {
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
// Normalize CRLF → LF before matching. The literal `\n` after the opening
|
||||
// `---` fence cannot match `\r`, so a Windows-authored SKILL.md (CRLF)
|
||||
// would fall through with raw=='' and silently lose every parsed field.
|
||||
// Normalizing here also gives downstream value parsers an LF-only body so
|
||||
// `[^"'\n]+?` character classes don't accidentally swallow a trailing `\r`.
|
||||
const normalized = content.replace(/\r\n/g, '\n');
|
||||
const fmMatch = normalized.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!fmMatch) return null;
|
||||
const raw = fmMatch[1];
|
||||
const out: ParsedFrontmatter = { raw };
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { extractFrontmatterBlock } from './markdown.ts';
|
||||
|
||||
export interface ManifestEntry {
|
||||
name: string;
|
||||
@@ -47,9 +48,8 @@ export interface ManifestLoadResult {
|
||||
function parseSkillName(skillMdPath: string): string | null {
|
||||
try {
|
||||
const content = readFileSync(skillMdPath, 'utf-8');
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!fmMatch) return null;
|
||||
const fm = fmMatch[1];
|
||||
const fm = extractFrontmatterBlock(content);
|
||||
if (fm === null) return null;
|
||||
// Match `name: foo` or `name: "foo"` or `name: 'foo'`
|
||||
const nameMatch = fm.match(/^name:\s*["']?([^"'\n]+?)["']?\s*$/m);
|
||||
if (!nameMatch) return null;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, statSync, readdirSync } from 'fs';
|
||||
import { join, dirname, isAbsolute, resolve } from 'path';
|
||||
import { join, dirname, isAbsolute, resolve, posix } from 'path';
|
||||
|
||||
import { parseMarkdown } from '../markdown.ts';
|
||||
|
||||
@@ -142,9 +142,12 @@ function walkFiles(absDir: string, prefix: string, out: BundleEntry[], sharedDep
|
||||
continue;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
walkFiles(abs, join(prefix, e), out, sharedDep);
|
||||
// relTarget is a portable bundle path; always use forward-slash
|
||||
// joining so Windows installs produce the same manifest shape as
|
||||
// Linux/macOS (callers downstream string-match on `alpha/SKILL.md`).
|
||||
walkFiles(abs, posix.join(prefix, e), out, sharedDep);
|
||||
} else if (stat.isFile()) {
|
||||
out.push({ source: abs, relTarget: join(prefix, e), sharedDep });
|
||||
out.push({ source: abs, relTarget: posix.join(prefix, e), sharedDep });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +410,38 @@ describe("v0.22.4 regression — actual repo skills/ has 0 errors", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRLF tolerance — Windows-authored SKILL.md must not cause mece_gap
|
||||
// false-positives. The frontmatter parser used to anchor on literal `\n`
|
||||
// after the leading `---`, which failed silently on `\r\n`-terminated files
|
||||
// and made every skill on Windows look like it was missing a triggers: array.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("checkResolvable — CRLF line endings", () => {
|
||||
let dir: string;
|
||||
afterEachCleanup(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
test("does not flag mece_gap for skills with CRLF-terminated frontmatter", () => {
|
||||
const lfBody = `---\nname: alpha\ndescription: alpha skill\ntriggers:\n - "alpha trigger"\n---\n\n# Alpha\n`;
|
||||
dir = mkdtempSync(join(tmpdir(), "gbrain-crlf-"));
|
||||
writeFileSync(
|
||||
join(dir, "RESOLVER.md"),
|
||||
`## Test\n| Trigger | Skill |\n|-----|-----|\n| "alpha trigger" | \`skills/alpha/SKILL.md\` |\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, "manifest.json"),
|
||||
JSON.stringify({ skills: [{ name: "alpha", path: "alpha/SKILL.md" }] }, null, 2),
|
||||
);
|
||||
mkdirSync(join(dir, "alpha"), { recursive: true });
|
||||
// Convert the otherwise-valid SKILL.md to CRLF — this is the actual
|
||||
// on-disk shape produced by editors on Windows.
|
||||
writeFileSync(join(dir, "alpha", "SKILL.md"), lfBody.replace(/\n/g, "\r\n"));
|
||||
|
||||
const report = checkResolvable(dir);
|
||||
const gaps = report.issues.filter(i => i.type === "mece_gap");
|
||||
expect(gaps).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// bun:test has no beforeEach/afterEach at module scope cleanly interacting
|
||||
// with closures; a small helper keeps cleanup readable and per-test.
|
||||
function afterEachCleanup(fn: () => void) {
|
||||
|
||||
@@ -136,6 +136,50 @@ describe('extractTimelineFromContent', () => {
|
||||
expect(entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not split a hyphenated word in the bullet body (bare-hyphen regression, #1341)', () => {
|
||||
// A hyphen inside a slug/compound must not be read as the source/summary
|
||||
// separator. Pre-fix this produced source "acme".
|
||||
const content = `- **2025-05-13** | acme-consulting-group kickoff call`;
|
||||
const entries = extractTimelineFromContent(content, 'test');
|
||||
expect(entries.find((e) => e.source === 'acme')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps hyphenated words intact in the summary when splitting on a spaced dash', () => {
|
||||
const content = `- **2025-05-13** | Call — acme-consulting-group renewed`;
|
||||
const entries = extractTimelineFromContent(content, 'test');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].source).toBe('Call');
|
||||
expect(entries[0].summary).toBe('acme-consulting-group renewed');
|
||||
});
|
||||
|
||||
it('still splits on a spaced plain hyphen separator', () => {
|
||||
// Bullets written as `Source - Summary` extracted fine pre-fix and must
|
||||
// keep extracting (the #1341 PR's em/en-dash-only regex dropped them).
|
||||
const content = `- **2025-05-13** | Standup - shipped the beta`;
|
||||
const entries = extractTimelineFromContent(content, 'test');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].source).toBe('Standup');
|
||||
expect(entries[0].summary).toBe('shipped the beta');
|
||||
});
|
||||
|
||||
it('still splits on an unspaced em dash separator', () => {
|
||||
const content = `- **2025-05-13** | Call—renewed the contract`;
|
||||
const entries = extractTimelineFromContent(content, 'test');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].source).toBe('Call');
|
||||
expect(entries[0].summary).toBe('renewed the contract');
|
||||
});
|
||||
|
||||
it('long and parenthesized sources still extract', () => {
|
||||
// The #1341 PR capped sources at 24 bracket-free chars, silently dropping
|
||||
// previously-extracted bullets. No such cap here.
|
||||
const content = `- **2025-05-13** | Quarterly business review (with acme-example team) — agreed on roadmap`;
|
||||
const entries = extractTimelineFromContent(content, 'test');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].source).toBe('Quarterly business review (with acme-example team)');
|
||||
expect(entries[0].summary).toBe('agreed on roadmap');
|
||||
});
|
||||
|
||||
it('extracts inline citation format entries', () => {
|
||||
const content = `Closed the seed round with fund-a leading. [Source: board meeting notes, 2025-04-02]`;
|
||||
const entries = extractTimelineFromContent(content, 'deals/acme-seed');
|
||||
|
||||
@@ -32,6 +32,14 @@ describe('lintContent', () => {
|
||||
expect(issues.some(i => i.rule === 'code-fence-wrap')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not flag a mid-document markdown code block as a page wrap', () => {
|
||||
// Regression: a note that merely CONTAINS a ```markdown block (e.g. a
|
||||
// Notion export fencing a config snippet) is not a whole-page wrap.
|
||||
const content = '---\ntitle: Notes\ntype: note\ncreated: 2026-05-25\n---\n\nIntro paragraph.\n\n```markdown\nKEY=value\nPORT=3002\n```\n';
|
||||
const issues = lintContent(content, 'test.md');
|
||||
expect(issues.some(i => i.rule === 'code-fence-wrap')).toBe(false);
|
||||
});
|
||||
|
||||
test('detects placeholder dates', () => {
|
||||
const content = '---\ntitle: Test\ntype: person\ncreated: YYYY-MM-DD\n---\n\n# Test';
|
||||
const issues = lintContent(content, 'test.md');
|
||||
@@ -97,6 +105,17 @@ describe('fixContent', () => {
|
||||
expect(fixed).toContain('# Title');
|
||||
});
|
||||
|
||||
test('leaves a mid-document markdown code block intact (keeps its closing fence)', () => {
|
||||
// Regression for the autopilot churn: fixContent used to strip only the
|
||||
// trailing ```, leaving the fence open and the note re-corrupted each cycle.
|
||||
const input = '---\ntitle: Notes\ntype: note\ncreated: 2026-05-25\n---\n\nIntro paragraph.\n\n```markdown\nKEY=value\nPORT=3002\n```\n';
|
||||
const fixed = fixContent(input);
|
||||
const fenceLines = (fixed.match(/^```/gm) ?? []).length;
|
||||
expect(fenceLines).toBe(2);
|
||||
expect(fixed).toContain('```markdown');
|
||||
expect(fixed.trimEnd().endsWith('```')).toBe(true);
|
||||
});
|
||||
|
||||
test('cleans up excessive blank lines after fix', () => {
|
||||
const input = 'Of course. Here is the brain page.\n\n\n\n# Title\n\nContent.';
|
||||
const fixed = fixContent(input);
|
||||
|
||||
@@ -21,12 +21,15 @@ beforeEach(() => {
|
||||
origHome = process.env.HOME;
|
||||
origGbrainHome = process.env.GBRAIN_HOME;
|
||||
tmp = mkdtempSync(join(tmpdir(), 'gbrain-prefs-test-'));
|
||||
// preferences.ts's gbrainDir() returns `$HOME/.gbrain` when GBRAIN_HOME
|
||||
// is unset. Test fixtures write to `$tmp/.gbrain/...`, so set HOME only
|
||||
// and clear GBRAIN_HOME — setting GBRAIN_HOME would route prefs to $tmp
|
||||
// directly (no .gbrain suffix), which doesn't match the fixture layout.
|
||||
// Both gbrainDir() (preferences) and configDir() (config) now share the
|
||||
// same contract via configDir(): GBRAIN_HOME is the parent dir, and they
|
||||
// always append `.gbrain`. So setting GBRAIN_HOME=$tmp routes prefs to
|
||||
// `$tmp/.gbrain/...`, matching the fixture layout used in this file.
|
||||
//
|
||||
// HOME alone is unreliable for isolation: os.homedir() reads USERPROFILE
|
||||
// on Windows, ignoring HOME. GBRAIN_HOME is the platform-neutral hook.
|
||||
process.env.HOME = tmp;
|
||||
delete process.env.GBRAIN_HOME;
|
||||
process.env.GBRAIN_HOME = tmp;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -178,6 +181,40 @@ describe('appendCompletedMigration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy GBRAIN_HOME path adoption', () => {
|
||||
// Before gbrainDir() delegated to configDir(), a set GBRAIN_HOME meant
|
||||
// preferences.json + migrations/completed.jsonl lived at $GBRAIN_HOME/...
|
||||
// directly (no `.gbrain` suffix). The unification must not orphan those
|
||||
// files — first access moves them to $GBRAIN_HOME/.gbrain/... .
|
||||
test('adopts a legacy preferences.json on load', () => {
|
||||
writeFileSync(join(tmp, 'preferences.json'), JSON.stringify({ minion_mode: 'off' }));
|
||||
expect(loadPreferences()).toEqual({ minion_mode: 'off' });
|
||||
expect(existsSync(join(tmp, '.gbrain', 'preferences.json'))).toBe(true);
|
||||
expect(existsSync(join(tmp, 'preferences.json'))).toBe(false);
|
||||
});
|
||||
|
||||
test('adopts a legacy migrations/completed.jsonl on load', () => {
|
||||
mkdirSync(join(tmp, 'migrations'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(tmp, 'migrations', 'completed.jsonl'),
|
||||
JSON.stringify({ version: '0.21.0', status: 'complete' }) + '\n',
|
||||
);
|
||||
const entries = loadCompletedMigrations();
|
||||
expect(entries.length).toBe(1);
|
||||
expect(entries[0].version).toBe('0.21.0');
|
||||
expect(existsSync(join(tmp, '.gbrain', 'migrations', 'completed.jsonl'))).toBe(true);
|
||||
});
|
||||
|
||||
test('new-path file wins over a stale legacy file', () => {
|
||||
mkdirSync(join(tmp, '.gbrain'), { recursive: true });
|
||||
writeFileSync(join(tmp, '.gbrain', 'preferences.json'), JSON.stringify({ minion_mode: 'always' }));
|
||||
writeFileSync(join(tmp, 'preferences.json'), JSON.stringify({ minion_mode: 'off' }));
|
||||
expect(loadPreferences()).toEqual({ minion_mode: 'always' });
|
||||
// Legacy file left untouched — never clobber the adopted copy.
|
||||
expect(existsSync(join(tmp, 'preferences.json'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCompletedMigrations', () => {
|
||||
test('returns empty when file is missing', () => {
|
||||
expect(loadCompletedMigrations()).toEqual([]);
|
||||
|
||||
@@ -26,7 +26,11 @@ let tmp: string;
|
||||
let origHome: string | undefined;
|
||||
|
||||
function run(args: string[]): { exitCode: number; stdout: string; stderr: string } {
|
||||
const env = { ...process.env, HOME: tmp } as Record<string, string | undefined>;
|
||||
// HOME alone doesn't redirect gbrain config on Windows: os.homedir() reads
|
||||
// USERPROFILE on Win32. GBRAIN_HOME is the platform-neutral override that
|
||||
// configDir() honors uniformly — set both so the test fixture isolates
|
||||
// from the real user's `.gbrain/` directory on every platform.
|
||||
const env = { ...process.env, HOME: tmp, GBRAIN_HOME: tmp } as Record<string, string | undefined>;
|
||||
delete env.DATABASE_URL;
|
||||
delete env.GBRAIN_DATABASE_URL;
|
||||
try {
|
||||
@@ -56,6 +60,12 @@ afterEach(() => {
|
||||
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
// Each test below spawns `bun run cli.ts skillpack-check`, which itself
|
||||
// spawns child `doctor` + `apply-migrations --list` processes. On Windows
|
||||
// subprocess cold-start is ~600ms each; the default 5s bun:test timeout
|
||||
// is too tight for the chained spawns. 30s leaves headroom for slower CI.
|
||||
const SUBPROCESS_TIMEOUT = 30_000;
|
||||
|
||||
describe('gbrain skillpack-check', () => {
|
||||
test('healthy fresh install → exit 0, healthy:true, empty actions', () => {
|
||||
const result = run(['skillpack-check']);
|
||||
@@ -66,7 +76,7 @@ describe('gbrain skillpack-check', () => {
|
||||
expect(report.summary).toBe('gbrain skillpack healthy');
|
||||
expect(report.version).toBeTruthy();
|
||||
expect(report.ts).toBeTruthy();
|
||||
});
|
||||
}, SUBPROCESS_TIMEOUT);
|
||||
|
||||
test('half-migrated (partial completed.jsonl) → exit 1, apply-migrations in actions', () => {
|
||||
const migrationsDir = join(tmp, '.gbrain', 'migrations');
|
||||
@@ -88,7 +98,7 @@ describe('gbrain skillpack-check', () => {
|
||||
const minions = doctorChecks.find(c => c.name === 'minions_migration');
|
||||
expect(minions).toBeDefined();
|
||||
expect(minions!.status).toBe('fail');
|
||||
});
|
||||
}, SUBPROCESS_TIMEOUT);
|
||||
|
||||
test('--quiet → no stdout, same exit code', () => {
|
||||
// Healthy path quiet
|
||||
@@ -106,7 +116,7 @@ describe('gbrain skillpack-check', () => {
|
||||
const broken = run(['skillpack-check', '--quiet']);
|
||||
expect(broken.exitCode).toBe(1);
|
||||
expect(broken.stdout).toBe('');
|
||||
});
|
||||
}, SUBPROCESS_TIMEOUT);
|
||||
|
||||
test('--help → exit 0, prints usage', () => {
|
||||
const result = run(['skillpack-check', '--help']);
|
||||
@@ -114,7 +124,7 @@ describe('gbrain skillpack-check', () => {
|
||||
expect(result.stdout).toContain('skillpack-check');
|
||||
expect(result.stdout).toContain('healthy');
|
||||
expect(result.stdout).toContain('Exit codes');
|
||||
});
|
||||
}, SUBPROCESS_TIMEOUT);
|
||||
|
||||
test('summary includes top action when multiple present', () => {
|
||||
// Partial record creates apply-migrations action + the migrations count
|
||||
@@ -130,5 +140,5 @@ describe('gbrain skillpack-check', () => {
|
||||
const report = JSON.parse(result.stdout);
|
||||
expect(report.summary).toMatch(/\d+ action\(s\)/);
|
||||
expect(report.summary).toContain(report.actions[0]);
|
||||
});
|
||||
}, SUBPROCESS_TIMEOUT);
|
||||
});
|
||||
|
||||
@@ -175,6 +175,17 @@ describe('enumerateBundle (D-CX-10 dependency closure)', () => {
|
||||
expect(targets.some(t => t.startsWith('alpha/'))).toBe(true);
|
||||
expect(targets.some(t => t.startsWith('beta/'))).toBe(true);
|
||||
});
|
||||
it('emits forward-slash relTarget paths on every platform', () => {
|
||||
// relTarget is a portable bundle key consumed by string-matching
|
||||
// callers (managed-block writer, install-plan diffs); Windows-style
|
||||
// backslashes silently break them. This test fails fast if anyone
|
||||
// re-introduces `path.join` for relTarget construction.
|
||||
const { gbrainRoot } = scratchGbrain();
|
||||
const m = loadBundleManifest(gbrainRoot);
|
||||
const entries = enumerateBundle({ gbrainRoot, manifest: m });
|
||||
const offending = entries.filter(e => e.relTarget.includes('\\'));
|
||||
expect(offending).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildManagedBlock + updateManagedBlock', () => {
|
||||
|
||||
@@ -1004,3 +1004,117 @@ describe('v0.42.52.0: 0-changes sync bumps last_sync_at heartbeat (D4 invariant
|
||||
expect(lastCommitRows[0]?.last_commit).toEqual(lastCommit);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#1430: failed git pull suppresses last_sync_at freshness', () => {
|
||||
let engine: PGLiteEngine;
|
||||
const repos: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (repos.length) {
|
||||
const d = repos.pop();
|
||||
if (d) rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function personMd(title: string, body: string): string {
|
||||
return ['---', 'type: person', `title: ${title}`, '---', '', body].join('\n');
|
||||
}
|
||||
|
||||
function mkRepo(files: Record<string, string>): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-pullfail-'));
|
||||
repos.push(dir);
|
||||
execSync('git init', { cwd: dir, stdio: 'pipe' });
|
||||
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
|
||||
execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
mkdirSync(join(dir, rel, '..'), { recursive: true });
|
||||
writeFileSync(join(dir, rel), content);
|
||||
}
|
||||
execSync('git add -A && git commit -m "initial"', { cwd: dir, stdio: 'pipe' });
|
||||
return dir;
|
||||
}
|
||||
|
||||
const BASE_OPTS = { noEmbed: true, noExtract: true, sourceId: 'default' } as const;
|
||||
|
||||
async function sourceRow(): Promise<{ last_sync_at: string | null; last_commit: string | null }> {
|
||||
const rows = await engine.executeRaw<{ last_sync_at: string | null; last_commit: string | null }>(
|
||||
`SELECT last_sync_at, last_commit FROM sources WHERE id = 'default'`,
|
||||
);
|
||||
return rows[0] ?? { last_sync_at: null, last_commit: null };
|
||||
}
|
||||
|
||||
test('up_to_date sync with a failed pull does NOT advance last_sync_at', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
const repo = mkRepo({ 'people/alice.md': personMd('Alice', 'Alice is a person.') });
|
||||
// Origin points nowhere so a pull is ATTEMPTED and FAILS (non-timeout).
|
||||
execSync('git remote add origin /nonexistent/repo.git', { cwd: repo, stdio: 'pipe' });
|
||||
|
||||
// Seed with --no-pull so the first sync succeeds and stamps freshness.
|
||||
await performSync(engine, { repoPath: repo, ...BASE_OPTS, noPull: true });
|
||||
const before = await sourceRow();
|
||||
expect(before.last_sync_at).not.toBeNull();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
|
||||
// Pull attempted (no noPull) → fails → up_to_date heartbeat suppressed.
|
||||
const result = await performSync(engine, { repoPath: repo, ...BASE_OPTS });
|
||||
expect(result.status).toBe('up_to_date');
|
||||
const after = await sourceRow();
|
||||
expect(after.last_sync_at).toEqual(before.last_sync_at);
|
||||
});
|
||||
|
||||
test('incremental sync with a failed pull advances last_commit but NOT last_sync_at', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
const repo = mkRepo({ 'people/alice.md': personMd('Alice', 'Alice is a person.') });
|
||||
execSync('git remote add origin /nonexistent/repo.git', { cwd: repo, stdio: 'pipe' });
|
||||
|
||||
await performSync(engine, { repoPath: repo, ...BASE_OPTS, noPull: true });
|
||||
const before = await sourceRow();
|
||||
expect(before.last_sync_at).not.toBeNull();
|
||||
|
||||
// Local commit so the next sync takes the incremental import path.
|
||||
writeFileSync(join(repo, 'people/bob.md'), personMd('Bob', 'Bob is a person.'));
|
||||
execSync('git add -A && git commit -m "add bob"', { cwd: repo, stdio: 'pipe' });
|
||||
const newHead = execSync('git rev-parse HEAD', { cwd: repo, encoding: 'utf-8' }).trim();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
|
||||
const result = await performSync(engine, { repoPath: repo, ...BASE_OPTS });
|
||||
expect(result.status).toBe('synced');
|
||||
const after = await sourceRow();
|
||||
// Local import converged: bookmark advances. Remote never observed:
|
||||
// freshness does not.
|
||||
expect(after.last_commit).toBe(newHead);
|
||||
expect(after.last_sync_at).toEqual(before.last_sync_at);
|
||||
});
|
||||
|
||||
test('dry-run on an up-to-date source does NOT advance last_sync_at', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
const repo = mkRepo({ 'people/alice.md': personMd('Alice', 'Alice is a person.') });
|
||||
|
||||
await performSync(engine, { repoPath: repo, ...BASE_OPTS, noPull: true });
|
||||
const before = await sourceRow();
|
||||
expect(before.last_sync_at).not.toBeNull();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
|
||||
const result = await performSync(engine, { repoPath: repo, ...BASE_OPTS, noPull: true, dryRun: true });
|
||||
expect(result.status).toBe('up_to_date');
|
||||
const after = await sourceRow();
|
||||
expect(after.last_sync_at).toEqual(before.last_sync_at);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user