Files
HologramAgent-DSH-Memory-Port/test/phase-b-g-static.test.js
jianf 6d9ad62a8f feat(memory): surface recall as a system section instead of history
Per-turn recall currently enters through the `agent/pre-step` waterfall, and
that hook can only return `messages`. The recall block therefore lands in
conversation history as a user message: the model reads its own memory as if a
user had said it. `systemPrompt.context` cannot fix this — it runs before the
turn text is known, so it can only render input-independent text.

The loop's `preStep` is the first seam holding BOTH the assembled prompt and
this turn's claimed messages. `lib/loop.js` subclasses the harness AgentLoop and
wraps that seam on the agent instance: a pure snapshot carrier message is moved
out of `messages` and into `assembly.sections`, so it becomes part of the system
prompt for that turn and never enters history.

Deliberately decoupled from the memory plugin: nothing imports it or matches its
name. Promotion keys off the shape every snapshot-style injector already uses
(`source.sections`, the same shape `dsh-time-context` emits).

Constraints honoured, each of which was a measured failure elsewhere:
- The promoted section carries `interpolate: false`; recalled text is free text
  and may contain `{{...}}`, which the strict interpolator rejects.
- The subclass defines no constructor and no static `inject`. The parent already
  registers the turnBoundary + inbox projections, calls `setFactory(this)`, and
  declares `{{provider}}`/`{{model}}`; re-registering any of them throws at
  startup.
- Only the instance's `preStep` is patched, never the prototype, which other
  agents share.

Also wires the new peer: `scripts/link-dsh.sh` gains dsh-agent/dsh-agent-loop
(without them the package cannot resolve at test time), package.json declares
dsh-agent-loop as a peer and adds the new test file, and G2b's allowlist admits
dsh-agent-loop as a first-party harness package, with the reason recorded.

npm test: 247 pass, 0 fail.
2026-11-04 14:54:49 +08:00

266 lines
12 KiB
JavaScript

/**
* Phase B · group G — static (source-text) guards.
*
* ★ This file deliberately imports **nothing** from `lib/`.
*
* G2b and G5 inspect source text. If they lived beside the behavioural guards,
* they would be imported through `lib/index.js` and a broken build would crash
* the whole file — producing "load failed" instead of the actual finding
* ("a disallowed dependency was added"). A guard that cannot report its own
* verdict is not a guard. Keeping this file dependency-free means it survives a
* broken `lib/` and names the offender.
*
* @module dsh-memory/test/phase-b-g-static
*/
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
const LIB = new URL('../lib/', import.meta.url).pathname
/** Every .js file under lib/, recursively. */
function libSources() {
const out = []
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name)
if (entry.isDirectory()) walk(full)
else if (entry.name.endsWith('.js')) out.push(full)
}
}
walk(LIB)
return out
}
const rel = (file) => file.replace(LIB, 'lib/')
test('G2b: lib/ imports only node: builtins and cordis/dsh peers', () => {
// A new runtime dependency (an ONNX binding, an HTTP client, a vector DB) shows
// up here before it shows up in a deployment.
// `dsh-agent-loop` joined this list with lib/loop.js: the loop subclass has to
// extend the harness's own AgentLoop, and that package is declared as a peer in
// package.json exactly like dsh-tools. It is still a first-party harness
// package, not the kind of new runtime dependency (a binding, an HTTP client,
// a vector DB) this guard exists to catch.
const allowed = /^(node:|@deepseek-ai\/(cordis|dsh-llm|dsh-system-prompt|dsh-tools|dsh-session|dsh-scope|dsh-agent-loop)|\.\/|\.\.\/)/
const offenders = []
for (const file of libSources()) {
const text = readFileSync(file, 'utf8')
for (const match of text.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)) {
const spec = match[1]
if (!allowed.test(spec)) offenders.push(`${file.replace(LIB, 'lib/')}: ${spec}`)
}
for (const match of text.matchAll(/^\s*(?:const|let)\s+\w+\s*=\s*require\(\s*['"]([^'"]+)['"]/gm)) {
const spec = match[1]
if (!allowed.test(spec)) offenders.push(`${file.replace(LIB, 'lib/')}: require(${spec})`)
}
}
assert.deepEqual(offenders, [], `disallowed runtime imports:\n${offenders.join('\n')}`)
})
// ── G5: provider identifiers confined to one file ────────────────────────
test('G5: provider-specific identifiers may appear in exactly one lib file', () => {
// The criterion is not "never mention http" — the transport has to live
// somewhere. It is "nowhere EXCEPT the adapter", so swapping the transport
// cannot require edits scattered across recall/scoring/storage.
// The only place allowed to know a transport. Kept as the PREFIX actually
// compared against `rel` — an earlier version compared against
// `ADAPTER.slice(4)` (== 'embed/'), which never matched 'lib/embed/...', so the
// allowlist silently did nothing. It stayed green only because lib/embed/ did
// not exist yet; the moment a legitimate adapter landed it would have flagged
// that adapter's own `fetch`. See G5†.
const ADAPTER = 'lib/embed/'
// Only CODE counts. Comments legitimately say "no endpoint" and "edge endpoint",
// and a scan that cannot tell prose from code produces false positives that
// get the whole criterion disabled. Strip comments, then scan.
const stripComments = (text) =>
text
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
.replace(/(^|[^:])\/\/.*$/gm, '$1') // line comments (not http://)
const forbidden = [
/\bfetch\s*\(/, // outbound call
/Authorization/i, // auth header
/\bBearer\b/,
/https?:\/\//, // literal URL
/\bmodel_dir\b/, // ONNX provider option
/\bapi_key\b/i, // provider credential
]
const offenders = []
for (const file of libSources()) {
const rel = file.replace(LIB, 'lib/')
if (rel.startsWith(ADAPTER)) continue // allowed area
const text = stripComments(readFileSync(file, 'utf8'))
for (const pattern of forbidden) {
const hit = text.match(pattern)
if (hit !== null) offenders.push(`${rel}: ${hit[0]}`)
}
}
assert.deepEqual(offenders, [], `transport leaked outside ${ADAPTER}:\n${offenders.join('\n')}`)
})
// ── mutations ─────────────────────────────────────────────────────────────
//
// These do not mutate lib/ on disk (a test must not edit the tree it is
// verifying). Instead each one re-runs the SAME detection logic over a synthetic
// file list, so the assertion is "the rule as written would have caught this".
// G5† is special: it re-implements the buggy prefix and proves the buggy version
// passes a legitimate adapter, which is exactly how the real bug stayed hidden.
/** The detection core, parameterised by the file list and the allowlist test. */
function detect(files, isAllowed) {
const forbidden = [/\bfetch\s*\(/, /Authorization/i, /\bBearer\b/, /https?:\/\//, /\bmodel_dir\b/, /\bapi_key\b/i]
const stripComments = (text) =>
text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
const offenders = []
for (const { rel, text } of files) {
if (isAllowed(rel)) continue
const code = stripComments(text)
for (const pattern of forbidden) {
const hit = code.match(pattern)
if (hit !== null) offenders.push(`${rel}: ${hit[0]}`)
}
}
return offenders
}
const LEGIT_ADAPTER = {
rel: 'lib/embed/http.js',
text: `export async function embedText(t) {
const r = await fetch('https://example.invalid/embed', {
headers: { Authorization: 'Bearer k' },
})
return (await r.json()).embedding
}`,
}
const VIOLATING_STORE = {
rel: 'lib/store.js',
text: `const r = await fetch('https://x/')`,
}
test('G5†: the OLD prefix (ADAPTER.slice(4)) fails a legitimate adapter — the real bug', () => {
const buggy = (rel) => rel.startsWith('lib/embed/'.slice(4)) // == 'embed/' → never true
const offenders = detect([LEGIT_ADAPTER], buggy)
// The buggy rule flags the adapter's own fetch: a guard that cries wolf.
assert.equal(
offenders.length > 0,
true,
'sanity: the buggy prefix must FAIL to exempt the adapter (that was the bug)',
)
// And that is why it had to be fixed: the correct rule exempts it.
const fixed = (rel) => rel.startsWith('lib/embed/')
assert.deepEqual(detect([LEGIT_ADAPTER], fixed), [], 'the fixed rule exempts the adapter')
})
test('G5b†: a fetch planted in lib/store.js must be reported, naming store.js', () => {
const fixed = (rel) => rel.startsWith('lib/embed/')
const offenders = detect([LEGIT_ADAPTER, VIOLATING_STORE], fixed)
// One line can trip several patterns (`fetch(` AND `https://`), so assert on
// WHICH FILE is named, not on the number of matches.
assert.ok(offenders.length >= 1, `expected at least one offender, got ${JSON.stringify(offenders)}`)
assert.ok(
offenders.every((o) => o.startsWith('lib/store.js: ')),
`every offender must name store.js; got ${JSON.stringify(offenders)}`,
)
assert.ok(
!offenders.some((o) => o.startsWith('lib/embed/')),
'the legitimate adapter must NOT be reported',
)
})
test('G2b†: a disallowed runtime import must be reported with file and package', () => {
const allowed = /^(node:|@deepseek-ai\/(cordis|dsh-llm|dsh-system-prompt|dsh-tools|dsh-session|dsh-scope)|\.\/|\.\.\/)/
const scanImports = (files) => {
const out = []
for (const { rel, text } of files) {
for (const m of text.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)) {
if (!allowed.test(m[1])) out.push(`${rel}: ${m[1]}`)
}
}
return out
}
const offenders = scanImports([
{ rel: 'lib/store.js', text: "import * as ort from 'onnxruntime-node'" },
{ rel: 'lib/recall.js', text: "import { createHash } from 'node:crypto'" },
])
assert.deepEqual(offenders, ['lib/store.js: onnxruntime-node'])
})
// ── the checkpoint runner must not be able to pass with zero checkpoints ──
//
// Replacing the old README-count guard silently removed its second assertion:
// "scripts/checkpoints.js declares at least one checkpoint". Nothing guards that
// any more, and the failure is invisible — stubbing the helper yields
// `0/0 checkpoints passed`, exit code 0, and every test still green.
//
// Two layers, because they catch different causes:
// · the runner itself exits non-zero when `results.length === 0` (rename,
// stub, conditional skip); and
// · this assertion proves the runner still contains that guard, because an
// early `process.exit(0)` bypasses any in-script check entirely.
test('scripts/checkpoints.js registers checkpoints AND refuses to pass with zero', () => {
const root = new URL('..', import.meta.url).pathname
const script = readFileSync(join(root, 'scripts', 'checkpoints.js'), 'utf8')
// 1. It actually registers something. Guards against a rename of the helper.
const declared = [...script.matchAll(/await checkpoint\('/g)].length
assert.ok(
declared > 0,
'scripts/checkpoints.js declares no `await checkpoint(...)` calls — did the helper get renamed?',
)
// 2. It refuses to report success when nothing ran. This is the guard that a
// stub or a conditional skip trips; an early `process.exit(0)` can only be
// caught here, by asserting the guard exists.
assert.match(
script,
/if \(results\.length === 0\)/,
'the runner must contain an explicit zero-results guard',
)
assert.match(
script,
/results\.length === 0[\s\S]{0,200}process\.exitCode = 1/,
'the zero-results guard must set a non-zero exit code',
)
})
// ── README: no unguarded counts ───────────────────────────────────────────
//
// This used to assert that README's test/checkpoint counts matched reality. That
// made the README a second source of truth which every test addition had to
// update — and it drifted five times. The decision was to remove the counts
// instead of guarding them: a number nobody verifies reads as precise while being
// wrong, and the guard cost more than the number was worth.
//
// So the invariant is now the opposite: the README must NOT state a count. If
// someone re-adds one, this fails and the reason is right here.
//
// Scope: this matches DIGIT forms ("119 tests", "10 checkpoints", "10/10 …").
// It does not try to catch a count spelled out in words ("four files"), because
// the README legitimately needs prose like "four files report ERR_MODULE_NOT_FOUND
// — that is the missing-setup symptom" and a prose-matching rule would either
// fire on that or grow into a mini parser. The rule targets the shape a
// maintainer actually writes when pasting a run result, which is digits.
test('README states no test or checkpoint counts', () => {
const readme = readFileSync(join(new URL('..', import.meta.url).pathname, 'README.md'), 'utf8')
const offenders = []
const patterns = [
[/\b\d+\s+tests\b/i, 'a test count'],
[/\b\d+\s+checkpoints\b/i, 'a checkpoint count'],
[/\b\d+\s*\/\s*\d+\s+checkpoints passed/i, 'a pasted pass-count'],
]
for (const [pattern, label] of patterns) {
const hit = readme.match(pattern)
if (hit !== null) offenders.push(`${label}: "${hit[0]}"`)
}
assert.deepEqual(
offenders,
[],
`README must not state counts — they drift and nobody guards them. Remove, don't update:\n${offenders.join('\n')}`,
)
})