* fix(sync): honor --dry-run in full-sync path + expose embedded count
Precondition for v0.17 brain maintenance cycle (runCycle primitive).
The full-sync path (performFullSync) previously called runImport() even
when opts.dryRun was true, silently writing to the DB and advancing
sync.last_commit. `gbrain sync --dry-run` on a fresh brain (or with
--full) would mutate state without warning.
Fix:
- performFullSync now early-returns a `dry_run` SyncResult when
opts.dryRun is set. Walks the repo via collectMarkdownFiles +
isSyncable to count what WOULD be imported. No writes, no git
state advance.
- SyncResult gains an `embedded: number` field (required). Tracks
pages re-embedded during the sync's auto-embed step. Existing
return sites set 0; the synced + first_sync paths set real counts
(best-estimate until commit 2 sharpens runEmbedCore's return type).
- first_sync path now returns real added + chunksCreated counts
from runImport instead of hardcoded zeros.
- printSyncResult shows embedded count in human output.
Tests (test/sync.test.ts, new `performSync dry-run never writes`
block, PGLite + temp git repo, no DATABASE_URL required):
- first-sync --dry-run: no pages, no sync.last_commit
- incremental --dry-run after real sync: bookmark unchanged
- --full --dry-run: no reimport, bookmark unchanged
- SyncResult.embedded is a number
Codex outside-voice caught this. Would have shipped silent DB writes
on dry-run for anyone using `gbrain sync --dry-run --full`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(embed): add dry-run mode + return EmbedResult with counts
Precondition for v0.17 brain maintenance cycle (runCycle primitive).
runEmbedCore previously returned Promise<void> and had no dry-run mode.
That made it impossible for runCycle to (a) report accurate embedded
counts or (b) honor --dry-run without also skipping the entire embed
phase (which would have required runCycle to know embed's internal
semantics — a layering violation).
Changes:
- EmbedOpts gains `dryRun?: boolean`. When set, embedPage and
embedAll enumerate stale chunks (or would-be-created chunks for
unchunked pages, via local chunkText without engine.upsertChunks)
but never call embedBatch and never write to the engine.
- runEmbedCore: Promise<void> -> Promise<EmbedResult>. Result shape:
{ embedded, skipped, would_embed, total_chunks, pages_processed,
dryRun }.
embedded = chunks newly embedded (0 in dryRun).
would_embed = chunks that WOULD be embedded (0 in non-dryRun).
skipped = chunks with pre-existing embeddings.
- runEmbed CLI wrapper honors --dry-run flag and returns the result
through. `gbrain embed --stale --dry-run` is now a safe preview.
- Callers ignoring the return value (sync auto-embed, autopilot
inline fallback, jobs.ts handlers, CLI) keep compiling — the new
return type is additive for `await` callers.
Tests (test/embed.test.ts, new `runEmbedCore --dry-run` block, uses
the existing mock.module embedBatch pattern, no API key required):
- dry-run --all: zero embedBatch calls, zero upsertChunks calls,
would_embed matches stale chunk total
- dry-run --stale correctly splits stale vs already-embedded counts
- dry-run --slugs on a single page tallies per-chunk counts
- non-dry-run regression guard: embedded count matches across
concurrent workers
Codex outside-voice flagged the Promise<void> return as a blocker for
accurate CycleReport.totals.pages_embedded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(orphans): engine-injected queries, drop db.getConnection() global
Precondition for v0.17 brain maintenance cycle (runCycle primitive).
findOrphans + queryOrphanPages previously reached into the postgres-js
singleton via db.getConnection(), which (a) didn't compose with
runCycle's explicit-engine contract and (b) was wrong for PGLite test
fixtures and for any caller not using the default global connection.
Codex outside-voice flagged this as a blocker.
Changes:
- BrainEngine interface gains findOrphanPages() — returns pages with
no inbound links via the same NOT EXISTS anti-join. Implemented on
both postgres-engine (sql tag) and pglite-engine (db.query).
- findOrphans signature: findOrphans(engine, { includePseudo }).
Engine is required. Uses engine.findOrphanPages() and
engine.getStats().page_count instead of raw SQL + global counts.
- queryOrphanPages signature: queryOrphanPages(engine). Delegates to
engine.findOrphanPages().
- src/commands/orphans.ts drops the `import * as db` — no more
global-state coupling.
- Callers updated: src/core/operations.ts find_orphans handler now
passes ctx.engine through; runOrphans CLI entry uses its engine arg.
- No signature change needed in cli.ts (it was already passing engine
via CLI_ONLY dispatch).
Tests (test/orphans.test.ts, new `findOrphans (engine-injected)`
describe block, PGLite in-memory, no DATABASE_URL required):
- links correctly scope orphans (alice links to bob -> bob not
an orphan; alice is)
- includePseudo:true surfaces _atlas-style pages
- queryOrphanPages delegates to passed engine
- empty brain returns {orphans: [], total_pages: 0} without crashing
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cycle): add runCycle primitive in src/core/cycle.ts
The brain maintenance cycle as a single function. Six phases in
semantically-driven order (fix files → sync → extract → embed →
report orphans). Pure composition of existing library calls — no
execSync, no subprocess anti-patterns, no regex-parsed output.
┌───────────────────────────────────────────────────┐
│ runCycle(engine, opts) → CycleReport │
│ Phase 1: lint --fix (fs writes) │
│ Phase 2: backlinks --fix (fs writes) │
│ Phase 3: sync (DB picks up 1+2) │
│ Phase 4: extract (DB picks up links) │
│ Phase 5: embed --stale (DB writes) │
│ Phase 6: orphans (DB read, report) │
└───────────────────────────────────────────────────┘
Why the commit-4 primitive:
- CEO + Eng + Codex reviews all converged on "extract one cycle
function, wire both dream and autopilot through it." Two CLIs,
one definition of what the brain does overnight.
- Phase order was wrong in PR #309's original dream.ts (sync
before lint+backlinks lost the "fix files, then index them"
semantic).
- This commit is the bisectable foundation; commit 5 (dream)
and commit 6 (autopilot+jobs) just call into it.
Coordination — the codex-flagged blocker:
Session-scoped pg_try_advisory_lock does not survive PgBouncer
transaction pooling (the v0.15.4 fix made pooled connections the
default). Replaced with a DB lock table (gbrain_cycle_locks) that
works through every pooler:
- Acquire: INSERT ... ON CONFLICT DO UPDATE ... WHERE ttl < NOW()
- Refresh: UPDATE ttl_expires_at between phases via hook
- Release: DELETE in finally{}
- TTL: 30 min; crashed holders auto-release
PGLite / engine=null path uses a file lock at ~/.gbrain/cycle.lock
with PID liveness check. kill(pid, 0) with EPERM treated as alive
(so init/launchd-pid holders aren't mis-classified as stale).
Lock-skip: only phases that mutate state (lint, backlinks, sync,
extract, embed) trigger lock acquisition. orphans is read-only.
Single-phase --phase orphans runs never block on a held lock.
Engine-null mode preserved: filesystem phases run, DB phases skip
with {status:'skipped', reason:'no_database'}. Matches current
dream's capability that would have been lost if runCycle required
a connected engine.
Contract details:
- CycleReport has schema_version:"1" (stable, additive) so agents
consuming --json can rely on the shape
- status: 'ok' | 'clean' | 'partial' | 'skipped' | 'failed'.
'clean' = ran successfully with zero activity; agents trivially
detect a healthy brain.
- PhaseResult.error: { class, code, message, hint?, docs_url? }
(Stripe-API-tier structured failure info) when status='fail'
- yieldBetweenPhases hook: awaited between EVERY phase and before
return, runs even after phase failure, exceptions logged but
non-fatal. Required so the Minions autopilot-cycle handler can
renew its job lock between phases (prevents the v0.14 stall-death
regression codex flagged).
- git pull explicit: opts.pull defaults to false (cron-safe).
Autopilot daemon callers opt in if user configured it.
- extract phase doesn't have a dry-run mode in the underlying
library function, so runCycle honestly skips extract when
dryRun=true (status:'skipped', reason:'no_dry_run_support').
Schema migration v16: gbrain_cycle_locks table + idx_cycle_locks_ttl.
Also appended to src/schema.sql and src/core/pglite-schema.ts for
fresh installs. schema-embedded.ts regenerated via build:schema.
Tests (test/core/cycle.test.ts, PGLite in-memory + mocked library
functions, no DATABASE_URL required):
- dryRun × phases matrix: dryRun:true reaches lint/backlinks/sync/
embed; extract is honestly skipped
- Phase selection: default runs all 6 in order; --phase lint runs
only lint; --phase orphans runs only orphans
- Lock semantics: acquire + release on mutating phases, skip
entirely for read-only selections
- cycle_already_running: seeded live-holder lock → status:skipped,
zero phase runs; TTL-expired holder → auto-claimed
- Engine null: filesystem phases run, DB phases skip
- File lock (engine=null) blocks when PID 1 holds lock with fresh
mtime — exercises the PID liveness branch including EPERM
- Status derivation: 'ok' vs 'clean' vs 'partial' vs 'skipped'
- yieldBetweenPhases called N times, hook exceptions non-fatal
Next: commit 5 rewrites dream.ts as a thin CLI alias over runCycle,
commit 6 migrates autopilot daemon + jobs.ts handler to delegate to
runCycle too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(dream): add gbrain dream CLI as a thin alias over runCycle
`gbrain dream` is the README brand-promise command: "the agent runs
while I sleep, the dream cycle ... I wake up and the brain is smarter."
Cron-friendly, JSON-reportable, phase-selectable. Same maintenance
cycle as `gbrain autopilot`, just scheduled differently — both
converge on runCycle (added in commit 4) so there's one source of
truth for what happens overnight.
Contract:
gbrain dream # full 6-phase cycle
gbrain dream --dry-run # preview, no writes
gbrain dream --json # CycleReport JSON (agent-readable)
gbrain dream --phase <name> # single-phase run
gbrain dream --pull # git pull before syncing
gbrain dream --dir /path/to/brain # explicit brain location
Cron: 0 2 * * * gbrain dream --json >> /var/log/gbrain-dream.log
Behavior details:
- Brain-dir resolution: requires explicit --dir OR sync.repo_path
in engine config. No more walk-up-cwd-for-.git footgun that
PR #309's original dream.ts had (would lint unrelated git repos).
- engine=null mode preserved via cli.ts's try/catch around
connectEngine — filesystem phases (lint, backlinks) still run
without a DB, DB phases report skipped/no_database in the output.
- status=clean prints "Brain is healthy. N phase(s) checked in Ns."
status=skipped prints the reason (cycle_already_running, etc.).
Partial/failed prints the phase-by-phase detail.
- Exit code 1 when status=failed (cron spots real problems).
'partial' is not a failure — warnings shouldn't page you.
- --help text cross-references `autopilot --install` for users
who want continuous maintenance as a daemon.
CLI registration (src/cli.ts):
- 'dream' added to CLI_ONLY
- handleCliOnly has a pre-engine branch mirroring doctor's pattern:
try connectEngine() → ok path; catch → runDream(null, args) so
filesystem phases still run when DB is down
- Help text updated with one-line dream entry and autopilot cross-ref
Tests (test/dream.test.ts, real PGLite + real library calls, no mocks
to avoid `mock.module` leakage across test files):
- brainDir resolution: explicit --dir wins, engine config fallback,
missing + nonexistent errors
- phase selection: --phase lint|orphans produces single-phase report
- phase validation: --phase garbage exits 1
- output: --json parses as CycleReport with schema_version:"1"
- human output mentions "Brain is healthy" on clean status
- dry-run: cycle runs but DB stays untouched
- exit code: clean/ok/partial do not call process.exit
Also (test/core/cycle.test.ts): refactored to use beforeAll/afterAll
with one shared PGLite engine per describe + truncateCycleLocks
between tests. Cuts test time from ~11s to ~4s; avoids the 15-migration
penalty per test that was causing parallel-suite timeout flakes.
Co-Authored-By: Wintermute <wintermute@garrytan.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: v0.17.0 — autopilot + jobs delegate to runCycle (unifies the cycle)
Autopilot daemon (`--inline` path) and Minions `autopilot-cycle`
handler both now delegate to `runCycle` (introduced in commit 4).
Three callers, one cycle definition:
1. `gbrain dream` — one-shot cron cycle
2. `gbrain autopilot` daemon inline path — scheduled cycles
3. `autopilot-cycle` Minions handler — durable queue with retry
All three share:
- Same 6 phases in same order (lint → backlinks → sync → extract →
embed → orphans)
- Same DB lock table coordination (`gbrain_cycle_locks`)
- Same yieldBetweenPhases discipline (prevents v0.14 stall-death)
- Same structured CycleReport output
Autopilot inline path gains lint + orphan sweep that the old path
skipped. Minions autopilot-cycle handler also gains lint + orphans.
Users who run `gbrain autopilot --install` see 6-phase reports in
`gbrain jobs get <id>` starting on next interval. No config change
required.
Changes:
- `src/commands/autopilot.ts`: inline fallback path (~20 lines)
replaces the ~22-line sync+extract+embed sequence with a single
runCycle call. Uses pull:true (matches pre-v0.17 autopilot
behavior). Uses setImmediate yield hook. Status/failure reporting
derives from CycleReport.status. `--help` cross-references `gbrain
dream` for one-shot use.
- `src/commands/jobs.ts:579` (`autopilot-cycle` handler): replaces
the 4-step try/catch sequence with a runCycle call. Returns
`{ partial, status, report }` so `gbrain jobs get <id>` shows the
full structured CycleReport. Preserves partial-failure semantic
(one phase failing does NOT throw; next cycle still runs).
yieldBetweenPhases yields the event loop between phases for the
worker's lock-renewal timer.
Release scaffolding:
- VERSION: 0.16.0 → 0.17.0
- CHANGELOG.md: v0.17.0 entry in GStack voice — headline, numbers
table, "what this means" paragraph, "To take advantage" block
per CLAUDE.md post-ship rules. Itemized changes below the fold.
Credit to @Wintermute for the original PR #309 thesis.
- skills/migrations/v0.17.0.md: documents what changed for
upgrading users. No mechanical action required — schema migration
v16 (cycle locks table) + handler delegation both apply
automatically. Includes opt-out paths for users who don't want
their daemon modifying files (use `dream --phase orphans` in cron
and skip autopilot-install, or other explicit configs).
- CLAUDE.md: new entries for `src/core/cycle.ts` and
`src/commands/dream.ts` with contract details.
Tests: no new test file needed for this commit — the cycle primitive
is extensively tested in test/core/cycle.test.ts (18 cases), dream
in test/dream.test.ts (11), and autopilot's delegation is mechanical
(calls runCycle with specific opts). The handler contract is covered
implicitly: if runCycle returns a CycleReport, the handler wraps it
in `{ partial, status, report }` — nothing else to assert.
Verified:
- `bun test test/autopilot-install.test.ts test/autopilot-resolve-cli.test.ts test/core/cycle.test.ts test/dream.test.ts` → 37 pass, 0 fail
Completes the v0.17.0 feature: 6 bisectable commits on one branch
(garrytan/v0.17-dream-cycle), ready to push as one PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(e2e): add runCycle + dream E2E coverage against real Postgres
Gap from the v0.17 commit series: PR #321 shipped unit-level tests
for runCycle (test/core/cycle.test.ts) and dream (test/dream.test.ts)
but no E2E coverage that exercises the real Postgres paths. Filling
that in before merge.
test/e2e/cycle.test.ts (6 cases):
- schema migration v16 created gbrain_cycle_locks + index
- dry-run full cycle: zero DB writes + lock table empty after
- live cycle: pages + chunks materialize, sync.last_commit set
- concurrent cycle blocked by lock → status:'skipped'
- TTL-expired lock auto-claimed (crashed-holder recovery)
- --phase orphans skips lock entirely (read-only optimization)
test/e2e/dream.test.ts (3 cases):
- dream --dry-run --json emits valid CycleReport + DB stays empty
- dream (no --dry-run) syncs pages into real DB
- dream --phase orphans doesn't touch the cycle-lock table
Both files mock embedBatch via mock.module so the embed phase never
calls OpenAI even when the full 6-phase cycle runs (zero API cost,
zero flakiness from network calls).
Verified locally:
- `docker run pgvector/pgvector:pg16` on port 5434
- `DATABASE_URL=... bun test test/e2e/cycle.test.ts test/e2e/dream.test.ts` → 9 pass, 0 fail
- Full E2E suite (`bun run test:e2e`): 16 files, 150 tests, 0 fail
- Container torn down after: `docker stop + rm gbrain-test-pg`
Per CLAUDE.md E2E test DB lifecycle. These tests skip gracefully when
DATABASE_URL isn't set (via hasDatabase() helper + describe.skip).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Wintermute <wintermute@garrytan.com>
293 lines
9.4 KiB
TypeScript
293 lines
9.4 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import {
|
|
shouldExclude,
|
|
deriveDomain,
|
|
formatOrphansText,
|
|
findOrphans,
|
|
queryOrphanPages,
|
|
type OrphanPage,
|
|
type OrphanResult,
|
|
} from '../src/commands/orphans.ts';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
|
|
// --- shouldExclude ---
|
|
|
|
describe('shouldExclude', () => {
|
|
test('excludes pseudo-page _atlas', () => {
|
|
expect(shouldExclude('_atlas')).toBe(true);
|
|
});
|
|
|
|
test('excludes pseudo-page _index', () => {
|
|
expect(shouldExclude('_index')).toBe(true);
|
|
});
|
|
|
|
test('excludes pseudo-page _stats', () => {
|
|
expect(shouldExclude('_stats')).toBe(true);
|
|
});
|
|
|
|
test('excludes pseudo-page _orphans', () => {
|
|
expect(shouldExclude('_orphans')).toBe(true);
|
|
});
|
|
|
|
test('excludes pseudo-page _scratch', () => {
|
|
expect(shouldExclude('_scratch')).toBe(true);
|
|
});
|
|
|
|
test('excludes pseudo-page claude', () => {
|
|
expect(shouldExclude('claude')).toBe(true);
|
|
});
|
|
|
|
test('excludes auto-generated _index suffix', () => {
|
|
expect(shouldExclude('companies/_index')).toBe(true);
|
|
expect(shouldExclude('people/_index')).toBe(true);
|
|
});
|
|
|
|
test('excludes auto-generated /log suffix', () => {
|
|
expect(shouldExclude('projects/acme/log')).toBe(true);
|
|
});
|
|
|
|
test('excludes raw source slugs', () => {
|
|
expect(shouldExclude('companies/acme/raw/crustdata')).toBe(true);
|
|
});
|
|
|
|
test('excludes deny-prefix: output/', () => {
|
|
expect(shouldExclude('output/2026-q1')).toBe(true);
|
|
});
|
|
|
|
test('excludes deny-prefix: dashboards/', () => {
|
|
expect(shouldExclude('dashboards/metrics')).toBe(true);
|
|
});
|
|
|
|
test('excludes deny-prefix: scripts/', () => {
|
|
expect(shouldExclude('scripts/ingest-runner')).toBe(true);
|
|
});
|
|
|
|
test('excludes deny-prefix: templates/', () => {
|
|
expect(shouldExclude('templates/meeting-note')).toBe(true);
|
|
});
|
|
|
|
test('excludes deny-prefix: openclaw/config/', () => {
|
|
expect(shouldExclude('openclaw/config/agent')).toBe(true);
|
|
});
|
|
|
|
test('excludes first-segment: scratch', () => {
|
|
expect(shouldExclude('scratch/idea-dump')).toBe(true);
|
|
});
|
|
|
|
test('excludes first-segment: thoughts', () => {
|
|
expect(shouldExclude('thoughts/2026-04-17')).toBe(true);
|
|
});
|
|
|
|
test('excludes first-segment: catalog', () => {
|
|
expect(shouldExclude('catalog/tools')).toBe(true);
|
|
});
|
|
|
|
test('excludes first-segment: entities', () => {
|
|
expect(shouldExclude('entities/product-hunt')).toBe(true);
|
|
});
|
|
|
|
test('does NOT exclude a normal content page', () => {
|
|
expect(shouldExclude('companies/acme')).toBe(false);
|
|
expect(shouldExclude('people/jane-doe')).toBe(false);
|
|
expect(shouldExclude('projects/gbrain')).toBe(false);
|
|
});
|
|
|
|
test('does NOT exclude a page ending with log-like text that is not /log', () => {
|
|
expect(shouldExclude('devlog')).toBe(false);
|
|
expect(shouldExclude('changelog')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// --- deriveDomain ---
|
|
|
|
describe('deriveDomain', () => {
|
|
test('uses frontmatter domain when present', () => {
|
|
expect(deriveDomain('companies', 'companies/acme')).toBe('companies');
|
|
});
|
|
|
|
test('falls back to first slug segment', () => {
|
|
expect(deriveDomain(null, 'people/jane-doe')).toBe('people');
|
|
expect(deriveDomain(undefined, 'projects/gbrain')).toBe('projects');
|
|
});
|
|
|
|
test('returns root for single-segment slugs with no frontmatter', () => {
|
|
expect(deriveDomain(null, 'readme')).toBe('readme');
|
|
});
|
|
|
|
test('ignores empty-string frontmatter domain', () => {
|
|
expect(deriveDomain('', 'people/alice')).toBe('people');
|
|
});
|
|
|
|
test('ignores whitespace-only frontmatter domain', () => {
|
|
expect(deriveDomain(' ', 'people/alice')).toBe('people');
|
|
});
|
|
});
|
|
|
|
// --- formatOrphansText ---
|
|
|
|
describe('formatOrphansText', () => {
|
|
function makeResult(orphans: OrphanPage[], overrides?: Partial<OrphanResult>): OrphanResult {
|
|
return {
|
|
orphans,
|
|
total_orphans: orphans.length,
|
|
total_linkable: orphans.length + 50,
|
|
total_pages: orphans.length + 60,
|
|
excluded: 10,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test('shows summary line', () => {
|
|
const result = makeResult([]);
|
|
const out = formatOrphansText(result);
|
|
expect(out).toContain('0 orphans out of');
|
|
expect(out).toContain('total');
|
|
expect(out).toContain('excluded');
|
|
});
|
|
|
|
test('shows "No orphan pages found." when empty', () => {
|
|
const out = formatOrphansText(makeResult([]));
|
|
expect(out).toContain('No orphan pages found.');
|
|
});
|
|
|
|
test('groups orphans by domain', () => {
|
|
const orphans: OrphanPage[] = [
|
|
{ slug: 'companies/acme', title: 'Acme Corp', domain: 'companies' },
|
|
{ slug: 'people/alice', title: 'Alice', domain: 'people' },
|
|
{ slug: 'companies/beta', title: 'Beta Inc', domain: 'companies' },
|
|
];
|
|
const out = formatOrphansText(makeResult(orphans));
|
|
expect(out).toContain('[companies]');
|
|
expect(out).toContain('[people]');
|
|
// companies section should appear before people (alphabetical)
|
|
const companiesIdx = out.indexOf('[companies]');
|
|
const peopleIdx = out.indexOf('[people]');
|
|
expect(companiesIdx).toBeLessThan(peopleIdx);
|
|
});
|
|
|
|
test('sorts orphans alphabetically within each domain group', () => {
|
|
const orphans: OrphanPage[] = [
|
|
{ slug: 'companies/zeta', title: 'Zeta', domain: 'companies' },
|
|
{ slug: 'companies/alpha', title: 'Alpha', domain: 'companies' },
|
|
{ slug: 'companies/beta', title: 'Beta', domain: 'companies' },
|
|
];
|
|
const out = formatOrphansText(makeResult(orphans));
|
|
const alphaIdx = out.indexOf('companies/alpha');
|
|
const betaIdx = out.indexOf('companies/beta');
|
|
const zetaIdx = out.indexOf('companies/zeta');
|
|
expect(alphaIdx).toBeLessThan(betaIdx);
|
|
expect(betaIdx).toBeLessThan(zetaIdx);
|
|
});
|
|
|
|
test('includes slug and title in output', () => {
|
|
const orphans: OrphanPage[] = [
|
|
{ slug: 'companies/acme', title: 'Acme Corp', domain: 'companies' },
|
|
];
|
|
const out = formatOrphansText(makeResult(orphans));
|
|
expect(out).toContain('companies/acme');
|
|
expect(out).toContain('Acme Corp');
|
|
});
|
|
|
|
test('summary line shows correct numbers', () => {
|
|
const orphans: OrphanPage[] = [
|
|
{ slug: 'a/b', title: 'B', domain: 'a' },
|
|
{ slug: 'a/c', title: 'C', domain: 'a' },
|
|
];
|
|
const result: OrphanResult = {
|
|
orphans,
|
|
total_orphans: 2,
|
|
total_linkable: 100,
|
|
total_pages: 120,
|
|
excluded: 20,
|
|
};
|
|
const out = formatOrphansText(result);
|
|
expect(out).toContain('2 orphans out of 100 linkable pages (120 total; 20 excluded)');
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// findOrphans + queryOrphanPages with explicit engine (v0.17 change)
|
|
// ────────────────────────────────────────────────────────────────
|
|
|
|
describe('findOrphans (engine-injected)', () => {
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeEach(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
test('returns pages with no inbound links, excluding pseudo-pages', async () => {
|
|
// Build a tiny brain: alice links to bob. alice is an orphan (nothing
|
|
// points to her), bob is not (alice points to him). _atlas is a pseudo
|
|
// page that should be excluded by default.
|
|
await engine.putPage('people/alice', {
|
|
type: 'person',
|
|
title: 'Alice',
|
|
compiled_truth: 'Alice works with Bob.',
|
|
timeline: '',
|
|
});
|
|
await engine.putPage('people/bob', {
|
|
type: 'person',
|
|
title: 'Bob',
|
|
compiled_truth: 'Bob.',
|
|
timeline: '',
|
|
});
|
|
await engine.putPage('_atlas', {
|
|
type: 'concept',
|
|
title: 'Atlas',
|
|
compiled_truth: 'pseudo-page',
|
|
timeline: '',
|
|
});
|
|
// Create the link alice -> bob.
|
|
await engine.addLink('people/alice', 'people/bob', 'mentioned', 'references', 'markdown');
|
|
|
|
const result = await findOrphans(engine);
|
|
|
|
const slugs = result.orphans.map(o => o.slug).sort();
|
|
expect(slugs).toEqual(['people/alice']); // _atlas excluded by default; bob has a backlink
|
|
expect(result.total_orphans).toBe(1);
|
|
expect(result.total_pages).toBe(3);
|
|
expect(result.excluded).toBeGreaterThanOrEqual(1); // _atlas was filtered
|
|
});
|
|
|
|
test('includePseudo: true surfaces pseudo-pages too', async () => {
|
|
await engine.putPage('_atlas', {
|
|
type: 'concept',
|
|
title: 'Atlas',
|
|
compiled_truth: 'pseudo',
|
|
timeline: '',
|
|
});
|
|
|
|
const result = await findOrphans(engine, { includePseudo: true });
|
|
|
|
const slugs = result.orphans.map(o => o.slug).sort();
|
|
expect(slugs).toContain('_atlas');
|
|
});
|
|
|
|
test('queryOrphanPages delegates to the passed engine (no global db)', async () => {
|
|
await engine.putPage('topic/standalone', {
|
|
type: 'concept',
|
|
title: 'Standalone',
|
|
compiled_truth: 'no inbound links',
|
|
timeline: '',
|
|
});
|
|
|
|
const rows = await queryOrphanPages(engine);
|
|
const slugs = rows.map(r => r.slug);
|
|
expect(slugs).toContain('topic/standalone');
|
|
});
|
|
|
|
test('zero pages: empty result (no crash on empty brain)', async () => {
|
|
const result = await findOrphans(engine);
|
|
expect(result.orphans).toEqual([]);
|
|
expect(result.total_orphans).toBe(0);
|
|
expect(result.total_pages).toBe(0);
|
|
});
|
|
});
|