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.
266 lines
12 KiB
JavaScript
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')}`,
|
|
)
|
|
})
|