Skip to content

feat: knowledge base doc-freshness layer (drift detection + refresh) #428

feat: knowledge base doc-freshness layer (drift detection + refresh)

feat: knowledge base doc-freshness layer (drift detection + refresh) #428

Workflow file for this run

name: CI
on:
push:
branches: [main, dev]
# Run on every PR regardless of base branch. The `branches` filter on
# pull_request only matches base, so stacked / long-lived branches
# (e.g. `optimizations`) would otherwise skip the whole CI job.
pull_request:
permissions:
contents: read
pull-requests: write
jobs:
duplication:
# Code-duplication regression guard. Pulled out of the `test` job so
# the PR checks table shows a dedicated pass/fail row — reviewers see
# at a glance whether the change introduced duplicated code without
# having to open the combined "Typecheck and Test" log.
name: Duplication check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
with:
# Read-only job: don't leave GITHUB_TOKEN in .git/config.
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 22
- name: Install dependencies
run: npm ci
- name: Run jscpd
# Threshold 7% is the current baseline (see .jscpd.json). The job
# fails if a future change pushes duplication above it, so the
# number is a regression guard — reviewers can see the exact
# clones in the markdown report uploaded below.
run: npm run dup
- name: Upload jscpd report
if: always()
uses: actions/upload-artifact@v7.0.1
with:
name: jscpd-report
path: jscpd-report/
if-no-files-found: ignore
windows-smoke:
# Windows leg for the win32-specific bugs fixed in the Codex/PowerShell
# report (2026-05-29):
# 1. capture hook crashed with exit 1 because `spawn("nohup", ...)`
# ENOENTs on Windows (no nohup) and the async 'error' event had no
# listener — fixed in src/utils/spawn-detached.ts.
# 2. re-install duplicated the PostToolUse hook because hook-dedup
# matched forward-slash paths while Windows writes backslash paths —
# fixed in src/cli/install-codex.ts + install-cursor.ts.
# 3. wiki-worker summary generation failed on Windows because the
# prompt was passed as a CLI arg (crashes with E2BIG / quoting
# issues) — fixed by writing to a temp file and passing the path
# instead (codex/cursor/pi/hermes workers, PR #250).
#
# Scoped to suites that cover those fixes rather than the full vitest run:
# the bulk of the suite assumes POSIX paths / chmod / symlinks and would
# fail on a Windows runner for reasons unrelated to these fixes.
# The wiki-worker suites mock execFileSync entirely (no real spawning,
# no POSIX-only paths), so they run cleanly on windows-latest.
name: Windows smoke (spawn + hook dedup + wiki-worker)
runs-on: windows-latest
steps:
- uses: actions/checkout@v6.0.2
with:
# Don't persist the checkout token into .git/config — it would be
# readable by later steps / artifacts. This job only reads + tests.
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 22
- name: Install dependencies
# tree-sitter is an optionalDependency; if its native build fails on
# Windows, npm continues — the scoped suites below don't import it.
run: npm install
- name: Run Windows-relevant suites
run: npx vitest run tests/shared/spawn-detached.test.ts tests/cli/install-helpers.test.ts tests/codex/codex-wiki-worker.test.ts tests/cursor/cursor-wiki-worker.test.ts tests/pi/pi-wiki-worker.test.ts tests/hermes/hermes-wiki-worker.test.ts
test:
name: Typecheck and Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
with:
# Full history so the "Build PR coverage comment" step can do
# `git diff origin/<base>...HEAD` to detect touched src/ files.
# Default shallow checkout (depth=1) produces "no merge base".
fetch-depth: 0
# Read-only job (diff is local once history is fetched): don't leave
# GITHUB_TOKEN in .git/config.
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 22
- name: Install dependencies
run: npm ci
- name: Build (typecheck + emit bundle artefacts)
# `build` runs `tsc && esbuild`, which is a strict superset of
# `typecheck` (the bare `tsc --noEmit` we used to run here). It
# ALSO produces the per-agent bundles AND `harnesses/openclaw/dist/`. The
# latter is gitignored, so it doesn't exist after a fresh
# `actions/checkout` — and several bundle-scan tests under
# `harnesses/claude-code/tests/skillify-session-start-injection.test.ts` read
# `harnesses/openclaw/dist/index.js` and `harnesses/openclaw/dist/skillify-worker.js`
# directly. Without this rebuild they fail with ENOENT (see PR #98
# — first CI run after the openclaw skillify wiring landed).
run: npm run build
- name: Audit openclaw bundle against ClawHub static-scan rules
# PR-time gate mirroring release.yml's audit step. Catches a
# flagged bundle BEFORE the PR merges instead of only at release
# time (where it'd correctly abort the publish, but the bad code
# would already be in main). `--criticals-only` blocks on
# critical findings only; warns are advisory (the openclaw
# skillify-worker's `readFileSync` + `fetch` warn is irreducible
# without splitting the worker into multiple shipped files).
# If this step trips, run `npm run audit:openclaw` locally and
# see scripts/audit-openclaw-bundle.mjs for the rule details.
run: npm run audit:openclaw -- --criticals-only
- name: Run tests with coverage
# Per-file 80% thresholds for PR #60 files are declared in
# vitest.config.ts under `coverage.thresholds`. Vitest exits non-zero
# if any of them regress below 80%, which fails the job.
run: npx vitest run --coverage
- name: Write coverage summary to job page
if: always()
run: |
if [ -f coverage/coverage-summary.json ]; then
echo "### Test Coverage (overall)" >> $GITHUB_STEP_SUMMARY
node -e "
const c = require('./coverage/coverage-summary.json').total;
const fmt = (v) => v.pct.toFixed(1) + '%';
console.log('| Metric | Coverage |');
console.log('|--------|----------|');
console.log('| Statements | ' + fmt(c.statements) + ' |');
console.log('| Branches | ' + fmt(c.branches) + ' |');
console.log('| Functions | ' + fmt(c.functions) + ' |');
console.log('| Lines | ' + fmt(c.lines) + ' |');
" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### PR-tracked files (must stay ≥ 80 %)" >> $GITHUB_STEP_SUMMARY
node -e "
const summary = require('./coverage/coverage-summary.json');
const tracked = [
'src/shell/grep-core.ts',
'src/shell/grep-interceptor.ts',
'src/hooks/grep-direct.ts',
];
console.log('| File | Stmts | Branch | Funcs | Lines |');
console.log('|------|------:|-------:|------:|------:|');
const fmt = v => v == null ? '—' : v.toFixed(1) + '%';
for (const rel of tracked) {
const key = Object.keys(summary).find(k => k.endsWith(rel));
const c = key ? summary[key] : null;
if (!c) { console.log('| \`' + rel + '\` | — | — | — | — |'); continue; }
console.log('| \`' + rel + '\` | ' + fmt(c.statements.pct) + ' | ' + fmt(c.branches.pct) + ' | ' + fmt(c.functions.pct) + ' | ' + fmt(c.lines.pct) + ' |');
}
" >> $GITHUB_STEP_SUMMARY
fi
- name: Build PR coverage comment
if: github.event_name == 'pull_request' && always()
id: pr-coverage
continue-on-error: true
env:
BASE_REF: ${{ github.base_ref }}
run: |
if [ ! -f coverage/coverage-summary.json ]; then
echo "no coverage summary — skipping PR comment"
echo "body-file=" >> "$GITHUB_OUTPUT"
exit 0
fi
node <<'NODE' > /tmp/pr-coverage.md
const { execSync } = require('node:child_process');
const fs = require('node:fs');
const summary = JSON.parse(fs.readFileSync('coverage/coverage-summary.json', 'utf8'));
const baseRef = process.env.BASE_REF || 'main';
const diff = execSync(`git diff --name-only origin/${baseRef}...HEAD`, { encoding: 'utf8' });
const changed = diff.split('\n')
.filter(f => f.startsWith('src/') && f.endsWith('.ts') && !f.endsWith('.test.ts') && !f.endsWith('.d.ts'))
.sort();
// Aggregate totals across ONLY the PR-touched files. Same shape
// as the davelosert "Coverage Report" box, but scoped to this PR
// instead of the whole src/ tree.
const agg = {
statements: { total: 0, covered: 0 },
branches: { total: 0, covered: 0 },
functions: { total: 0, covered: 0 },
lines: { total: 0, covered: 0 },
};
const perFile = [];
for (const f of changed) {
const key = Object.keys(summary).find(k => k.endsWith(f));
if (!key) { perFile.push({ f, c: null }); continue; }
const c = summary[key];
for (const k of Object.keys(agg)) {
agg[k].total += c[k].total;
agg[k].covered += c[k].covered;
}
perFile.push({ f, c });
}
const THRESHOLD = 90;
const pct = (m) => m.total === 0 ? null : (m.covered / m.total) * 100;
const icon = (p) => p == null ? '⚪' : (p >= THRESHOLD ? '🟢' : '🔴');
const fmtPct = (p) => p == null ? '—' : p.toFixed(2) + '%';
const fmtCell = (c, k) => c ? ((c[k].pct >= THRESHOLD ? '🟢 ' : '🔴 ') + c[k].pct.toFixed(1) + '%') : '—';
const out = [];
out.push('## Coverage Report');
out.push('');
if (changed.length === 0) {
out.push('_No `src/*.ts` files changed in this PR._');
} else {
out.push('Scope: files changed in this PR. Enforced threshold: **' + THRESHOLD + '%** per metric (per file via `vitest.config.ts`).');
out.push('');
out.push('| Status | Category | Percentage | Covered / Total |');
out.push('|--------|----------|-----------:|----------------:|');
for (const [label, key] of [["Lines","lines"],["Statements","statements"],["Functions","functions"],["Branches","branches"]]) {
const p = pct(agg[key]);
out.push(`| ${icon(p)} | ${label} | ${fmtPct(p)} (🎯 ${THRESHOLD}%) | ${agg[key].covered} / ${agg[key].total} |`);
}
out.push('');
// File-level breakdown inside a <details> dropdown so a PR that
// touches dozens/hundreds of files does not produce an endless
// comment. Summary text shows the file count so you can see at
// a glance how much is inside before expanding.
out.push('<details>');
out.push(`<summary><strong>File Coverage</strong> — ${perFile.length} file${perFile.length === 1 ? '' : 's'} changed</summary>`);
out.push('');
out.push('| File | Stmts | Branches | Functions | Lines |');
out.push('|------|------:|---------:|----------:|------:|');
for (const { f, c } of perFile) {
out.push(`| \`${f}\` | ${fmtCell(c, 'statements')} | ${fmtCell(c, 'branches')} | ${fmtCell(c, 'functions')} | ${fmtCell(c, 'lines')} |`);
}
out.push('');
out.push('</details>');
}
out.push('');
out.push(`<sub>Generated for commit ${(process.env.GITHUB_SHA || '?').slice(0,7)}.</sub>`);
console.log(out.join('\n'));
NODE
echo "body-file=/tmp/pr-coverage.md" >> "$GITHUB_OUTPUT"
- name: Post coverage comment on PR
# Fork PRs get a read-only GITHUB_TOKEN: the comment API returns
# "Resource not accessible by integration", failing the job and
# skipping the bundle build/smoke steps below. Skip the comment
# there — the coverage gate itself already ran above.
if: github.event_name == 'pull_request' && always() && steps.pr-coverage.outputs.body-file != '' && github.event.pull_request.head.repo.full_name == github.repository
uses: marocchino/sticky-pull-request-comment@v3.0.4
with:
header: pr-coverage-report
path: /tmp/pr-coverage.md
- name: Build bundles
run: npm run build
- name: Smoke-test built bundles parse cleanly
# Replaces the prior "Verify bundle/ directories are up to date"
# diff guard, which only made sense when bundles were tracked in
# git. Now bundles are gitignored on main and live only at release
# commits referenced by `marketplace.json`'s sha-pinned source.
# Without the diff guard, a build that emits syntactically broken
# JS would ship silently — so this step `node --check`s every
# bundle entrypoint after `npm run build` runs. --check is a
# parse-only validation: catches syntax/parse errors without
# executing module top-level code (which would spawn workers,
# open file handles, try to write to disk, etc.).
run: |
set -e
for f in \
bundle/cli.js \
harnesses/claude-code/bundle/capture.js \
harnesses/claude-code/bundle/session-start.js \
harnesses/claude-code/bundle/session-end.js \
harnesses/claude-code/bundle/plugin-cache-gc.js \
harnesses/claude-code/bundle/skillify-worker.js \
harnesses/codex/bundle/capture.js \
harnesses/codex/bundle/session-start.js \
harnesses/cursor/bundle/capture.js \
harnesses/cursor/bundle/session-start.js \
harnesses/hermes/bundle/capture.js \
harnesses/hermes/bundle/session-start.js \
mcp/bundle/server.js \
harnesses/pi/bundle/skillify-worker.js; do
if [ ! -f "$f" ]; then
echo "::error::expected bundle entrypoint missing: $f"
exit 1
fi
node --check "$f" || {
echo "::error::bundle failed to parse: $f"
exit 1
}
done
echo "All bundle entrypoints parse cleanly."
- name: Pack-check (refuse forbidden filenames in tarball)
# Regression guard: hard-fails CI if a PR widens package.json's
# `files` array (or adds a permissive .npmignore) such that
# credentials, CI workflows, or git internals would ship to npm.
# Mirrors the same gate inside release.yml's publish job, so a
# broken `files` array trips on the PR — never on a release run
# where tokens are reachable.
run: npm run pack:check
windows-test:
# Full typecheck + test suite on a real Windows runner, gated to push
# events only (not PRs) to keep PR feedback fast. Catches the class of
# Windows-only failures that mocked unit tests and Linux CI miss:
# path-separator bugs, CRLF handling, missing POSIX binaries, and
# node:fs behaviour differences (no chmod/symlinks). Mirrors the `test`
# job exactly except:
# - `defaults: run: shell: bash` — forces all `run:` steps through
# Git Bash (pre-installed on windows-latest) so the existing bash
# scripts (coverage summary, bundle smoke-test) work unchanged.
# - `npm install` instead of `npm ci` — if tree-sitter's native
# prebuild doesn't match the runner's ABI, npm continues rather
# than aborting (same pattern as the windows-smoke job).
# - PR-only steps omitted (coverage comment posting requires a PR
# context and a writable GITHUB_TOKEN the push job doesn't need).
name: Typecheck and Test (Windows)
runs-on: windows-latest
if: github.event_name == 'push'
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 22
- name: Install dependencies
run: npm install
- name: Build (typecheck + emit bundle artefacts)
run: npm run build
- name: Audit openclaw bundle against ClawHub static-scan rules
run: npm run audit:openclaw -- --criticals-only
- name: Run tests
# No --coverage here: this job exists to catch Windows-only test
# FAILURES, and per-file coverage thresholds are enforced on the Linux
# `test` job. Several suites are platform-skipped on Windows
# (skipIf(win32) for the Unix-socket embed daemon, /bin/sh safe-echo,
# Unix file-mode bits, etc.), so their src files can't meet the
# thresholds here by design — enforcing coverage on Windows would fail
# on those skips, not on any real regression.
run: npx vitest run
- name: Write coverage summary to job page
if: always()
run: |
if [ -f coverage/coverage-summary.json ]; then
echo "### Test Coverage (overall)" >> $GITHUB_STEP_SUMMARY
node -e "
const c = require('./coverage/coverage-summary.json').total;
const fmt = (v) => v.pct.toFixed(1) + '%';
console.log('| Metric | Coverage |');
console.log('|--------|----------|');
console.log('| Statements | ' + fmt(c.statements) + ' |');
console.log('| Branches | ' + fmt(c.branches) + ' |');
console.log('| Functions | ' + fmt(c.functions) + ' |');
console.log('| Lines | ' + fmt(c.lines) + ' |');
" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### PR-tracked files (must stay ≥ 80 %)" >> $GITHUB_STEP_SUMMARY
node -e "
const summary = require('./coverage/coverage-summary.json');
const tracked = [
'src/shell/grep-core.ts',
'src/shell/grep-interceptor.ts',
'src/hooks/grep-direct.ts',
];
console.log('| File | Stmts | Branch | Funcs | Lines |');
console.log('|------|------:|-------:|------:|------:|');
const fmt = v => v == null ? '—' : v.toFixed(1) + '%';
for (const rel of tracked) {
const key = Object.keys(summary).find(k => k.endsWith(rel));
const c = key ? summary[key] : null;
if (!c) { console.log('| \`' + rel + '\` | — | — | — | — |'); continue; }
console.log('| \`' + rel + '\` | ' + fmt(c.statements.pct) + ' | ' + fmt(c.branches.pct) + ' | ' + fmt(c.functions.pct) + ' | ' + fmt(c.lines.pct) + ' |');
}
" >> $GITHUB_STEP_SUMMARY
fi
- name: Build bundles
run: npm run build
- name: Smoke-test built bundles parse cleanly
run: |
set -e
for f in \
bundle/cli.js \
harnesses/claude-code/bundle/capture.js \
harnesses/claude-code/bundle/session-start.js \
harnesses/claude-code/bundle/session-end.js \
harnesses/claude-code/bundle/plugin-cache-gc.js \
harnesses/claude-code/bundle/skillify-worker.js \
harnesses/codex/bundle/capture.js \
harnesses/codex/bundle/session-start.js \
harnesses/cursor/bundle/capture.js \
harnesses/cursor/bundle/session-start.js \
harnesses/hermes/bundle/capture.js \
harnesses/hermes/bundle/session-start.js \
mcp/bundle/server.js \
harnesses/pi/bundle/skillify-worker.js; do
if [ ! -f "$f" ]; then
echo "::error::expected bundle entrypoint missing: $f"
exit 1
fi
node --check "$f" || {
echo "::error::bundle failed to parse: $f"
exit 1
}
done
echo "All bundle entrypoints parse cleanly."
- name: Pack-check (refuse forbidden filenames in tarball)
run: npm run pack:check
cross-node-install:
# Cross-Node-major install + build canary. Catches the class of bug
# where a native dep's prebuild ABI no longer matches a newer Node
# major and the fall-back compile fails — silently, because the
# offending package is now an optionalDependency. That exact pattern
# broke the post-merge Release run on PR #206 (tree-sitter@0.21 vs
# Node 24): the main `test` job above runs only on Node 22 where
# the prebuild matches, so the failure was invisible at PR time and
# only surfaced on `main` when release.yaml booted Node 24.12.
#
# This job runs the same install + build on every Node major the
# `engines` field admits, with HIVEMIND_STRICT_POSTINSTALL=1 so the
# ensure-tree-sitter.mjs heal turns "WARNING: bindings still
# unavailable" into a hard `exit 1` instead of swallowing it. The
# result: a Node-24-only install regression now fails this canary
# job at PR time with a clear error, not on the post-merge Release.
#
# `continue-on-error: true` keeps PRs unblocked while a known
# upstream Node-major break (e.g. the current tree-sitter@0.21 +
# Node 24 incompatibility) is being resolved. The signal is visible
# in the PR checks table as a yellow/red row. Once the underlying
# incompatibility is fixed (or matrix entries adjusted), flip this
# to `continue-on-error: false` to make the canary blocking.
name: Cross-Node install canary (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
continue-on-error: true
strategy:
# fail-fast: false so a failure on one Node major doesn't cancel
# the other — both signals are useful in the PR checks table.
fail-fast: false
matrix:
# Each major the `engines` field (">=22.0.0") admits. Bump this
# list when a new LTS lands (28, 30, …) or when 22 falls out of
# the support window. setup-node picks the latest available
# minor/patch for each major.
node-version: [22, 24]
env:
# Opt the heal into strict mode for our own CI runs only.
# Downstream consumers of @deeplake/hivemind never see this flag,
# so their install stays non-fatal.
HIVEMIND_STRICT_POSTINSTALL: "1"
steps:
- uses: actions/checkout@v6.0.2
with:
# Read-only job: don't leave GITHUB_TOKEN in .git/config.
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
# If the postinstall heal cannot make tree-sitter loadable under
# this Node major, HIVEMIND_STRICT_POSTINSTALL=1 makes ensure-
# tree-sitter.mjs exit 1 here, failing the job at the earliest
# possible step (instead of swallowing the warning and failing
# later in tsc with a confusing "Cannot find module" error).
run: npm ci
- name: Build (typecheck + emit bundle artefacts)
# Second backstop: if the strict-postinstall path somehow doesn't
# catch a broken native dep, the tsc step downstream of it will,
# because `import Parser from 'tree-sitter'` (src/graph/extract/
# typescript.ts) needs the package resolvable at type-check time.
run: npm run build