* feat(#656): add Research Store module (content-addressed cache, TTL staleness) Content-addressed research cache behind a clock seam: researchKey (sha256, deterministic), putResearch/getResearch ({hit,stale}, never throws), ttlForSource (curated HIGH 30d / MED 7d / web LOW 1d), two-tier resolveStorePath (curated -> ~/.gsd/research-cache, web/synthesis -> project .planning/research/.cache). 28 behavioral + property tests; boundary coverage at ttl-1/ttl/ttl+1. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(#656): add Research Provider module (waterfall + confidence + plan) Single source of truth for the Balanced provider waterfall (docs Context7->Ref->Jina, web Exa+Tavily, fallback Perplexity/Brave, Firecrawl scrape-only). classifyConfidence stamps HIGH|MEDIUM|LOW by provider (never throws). providerAvailability maps config flags to usable providers. planResearch checks the Research Store (injected seam) and returns cache-hits + a per-question fetch plan, falling through the waterfall to the always-available websearch terminal. 22 behavioral + property tests. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(#656): add Package Legitimacy module (registry-API verdicts, slopcheck optional) Replaces the pip-install-or-degrade slopcheck prose gate with code: classifyPackage (pure, never throws) computes OK|SUS|SLOP from tunable thresholds (minAgeDays 30, minWeeklyDownloads 1000, requireRepo). checkPackages queries injectable npm/PyPI/crates registry adapters (real https with 5s timeout, degraded-not-thrown on failure); slopcheck is one optional adapter that can only escalate severity, never degrade to [ASSUMED]. 34 behavioral + property tests; boundary coverage on age and downloads (limit-1/limit/limit+1). Known follow-up: real npm adapter must add api.npmjs.org last-week downloads fetch (currently null -> unknown-downloads). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(#656): detect Tavily/Ref/Perplexity/Jina provider keys; complete npm downloads adapter config: add tavily_search/ref_search/perplexity/jina availability flags (env var or ~/.gsd/<x>_api_key), mirroring brave_search/exa_search/firecrawl, so the Research Provider waterfall can gate them. package-legitimacy: real npm adapter now fetches api.npmjs.org last-week downloads (bounded, degraded-not-thrown) so weeklyDownloads is populated. +12 config tests; 34 legitimacy tests unchanged. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(#656): expose Research seam via gsd-tools query (research-plan, research-store, package-legitimacy) Routes the L2-hybrid surface so agents reach it as CLI: 'query research-store get/put' (cache, HOME-sandboxable), 'query research-plan --input' (cache-hits + fetch plan from planResearch), 'query package-legitimacy check --ecosystem' (async registry verdicts). Commands skip .planning root resolution and appear in top-level usage. 5 behavioral runGsdTools tests; command-contract unchanged (335). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(#656): document Research module (CONTEXT predicates, ADR-0656, architecture, changeset) Adds GSD-RESEARCH.* + DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT predicates to CONTEXT.md, ADR-0656 recording the L2-hybrid seam decision, a docs/ARCHITECTURE.md Research Module subsection, and an Added changeset fragment (pr:0, backfill on PR). Notes the #657 deferrals (agent collapse + install.js MCP mapping). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#656): sync inventory for research modules Regenerate INVENTORY-MANIFEST.json and bump docs/INVENTORY.md CLI Modules count 82->85 with rows for research-store/research-provider/package-legitimacy (DEFECT.INVENTORY-DRIFT). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#656): eslint-ignore generated research .cjs artifacts (ADR-457) research-store/research-provider/package-legitimacy .cjs are tsc-generated from src/*.cts, so they belong in the ESLint ignore block (lint the .cts source, not the emitted .cjs). Fixes tests/551-eslint-bin-lib-coverage. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#656): backfill changeset pr number to #664 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#656): satisfy eslint lint-tests gate Fix 20 eslint errors in the new research files: use helpers.cleanup() instead of raw fs.rmSync() in tests (local/no-raw-rmsync-in-tests, Windows-EBUSY retry budget); drop redundant '| string' union members and unnecessary type assertions; deterministic object normalization in researchKey (no-base-to-string). Logic unchanged; 6180 tests still green. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#656): harden package legitimacy per review (W1/W2/I3/I4) W1: httpsGet now reads statusCode; npm/PyPI/crates map 404 -> exists:false -> SLOP (registry-existence is the #1 slopsquatting defense; previously only npm caught it). Transport made injectable (_setHttpGet) for hermetic 404 tests. W2: suspicious-postinstall is now terminal SLOP independent of the optional slopcheck adapter, and the regex drops the bare https?:// arm (over-fired on esbuild/sharp/node-gyp) for shell-exec/download-exec signatures only. I3: checkPackages now threads version to registry.lookup and adapters verify that specific version exists. I4: moreServerVerdict -> moreSevereVerdict. +11 regression tests (all RED-first); 45 total green. Addresses review by @davesienkowski on #664. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#656): research-store tier coherence + freshness + version TTL (W4/I1/I2/I4) I1: tier now derives from source (curated -> user ~/.gsd, else -> project .planning), not kind, so put-tier and get-tier can't diverge; kind is a key component only. W4: getResearch searches both tiers and returns the freshest (non-stale preferred), never letting a stale curated entry shadow a fresh web one; blank version caps TTL at 1 day (no 30d on version-blind keys). I2: atomic platformWriteSync instead of raw fs.writeFileSync on the shared global path. I4: dropped the dead ttlForSource arm. CLI get now searches both tiers. +5 RED-first regression tests; 38 green. Addresses review by @davesienkowski on #664. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#656): expose classifyConfidence as a CLI route, killing dead code (W3) Adds 'gsd-tools query classify-confidence --provider X [--verified]' so research agents get the confidence tier FROM CODE (provider waterfall + verification lever) instead of asserting it in prose. classifyConfidence previously had no runtime caller. HIGH means 'trusted provider'; --verified raises web results to MEDIUM (verification semantics documented in ADR-0656). +4 behavioral tests. Addresses review by @davesienkowski on #664 (W3). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#656): close Codex adversarial-review findings (path-traversal, version-age, malformed-cache) HIGH: research key must be 64-hex sha256 (isValidResearchKey) + resolved-path containment check in put/get + CLI validation -> blocks '../../x' arbitrary-file-write. HIGH: package legitimacy now derives publishedAt from the REQUESTED version (npm time[version], PyPI releases[version] upload_time, crates versions[].created_at) so a new malicious version of an old package can't inherit old age and evade 'too-new'. MEDIUM: getResearch validates entry shape (finite fetched_at + positive ttl + required fields) -> malformed cache entry is a miss, not fresh-forever. +regression tests (RED-first); 111 green. Codex adversarial review (required pre-PR gate). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#656): close code-review correctness findings (1) package-legitimacy CLI now rejects unknown --flags instead of silently consuming the following package as a flag value; only --ecosystem takes a value. (2) crates recent_downloads (90-day) normalized to a weekly figure before the minWeeklyDownloads threshold (was ~13x too lenient). (3) research-plan --input validates parsed JSON is an object with an Array questions before destructuring -> clean usage error instead of an uncaught TypeError on null/bad input. (4) research-store put rejects a flag value that is itself a --flag (no more storing '--source' as content). (5) planResearch skips questions whose text is not a non-empty string instead of emitting question:undefined. +13 RED-first regression tests; 143 green. Code-review gate. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(#657): extract researcher documentation_lookup to shared @-reference 6 researcher agents carried a near-duplicate <documentation_lookup> block; consolidate into gsd-core/references/research-documentation-lookup.md (@-included). Unifies the ctx7 CLI fallback to the safer 'command -v ctx7' guard (drops silent 'npx --yes ctx7@latest' execution in 5 agents). Behavior-preserving dedup; inventory 63->64 references. Phase A of the agent collapse. Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(#657): extract researcher philosophy + verification-protocol to shared @-references philosophy and the pitfalls+pre-submission-checklist common-core were near-duplicated in project/phase researchers; consolidate into gsd-core/references/research-{philosophy,verification-protocol}.md (@-included). phase-researcher keeps its 3 extra checklist items inline. Pre-submission domains checklist made agent-agnostic so project-researcher doesn't lose features/architecture coverage. Write-contract intentionally left inline (bug-214 tests assert it verbatim). Inventory 64->66 refs. Behavior-preserving. Phase A. Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(#657): wire gsd-phase-researcher to the Research seam (Phase B / S1) The phase researcher now CALLS the code seam instead of carrying inline mechanics: provider waterfall -> 'gsd-tools query research-plan' (+ research-store put to cache digests); confidence-tier prose -> 'gsd-tools query classify-confidence'; slopcheck pip-install protocol -> 'gsd-tools query package-legitimacy check'. This makes the Research module a real runtime consumer (validates the seam end-to-end, addresses reviewer S1) and removes the duplicated waterfall/confidence/slopcheck prose. RESEARCH.md output contract, commit step, structured returns, and Phase-A @-includes unchanged. package-legitimacy-gate.test.cjs rewritten prose-grep -> behavioral (asserts the seam invocation). Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(#657): wire gsd-project-researcher to the seam + add tavily/ref/jina MCP tools (Phase C.1) project-researcher now calls gsd-tools query research-plan / classify-confidence (+ research-store put) instead of the inline provider waterfall + confidence-tier prose (mirrors the phase-researcher rewire; no package-legitimacy — phase-only). Output contract (STACK/FEATURES/ARCHITECTURE/PITFALLS/SUMMARY.md + sections, no-commit, structured returns, Phase-A @-includes) unchanged. Adds mcp__tavily/ref/jina__* to the project/phase/ui researcher tools frontmatter (Balanced provider set) so install.js MCP mapping (C.2) has a consumer. Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(#657): cover tavily/ref/jina MCP install handling + frontmatter parity guard (Phase C.2) Investigation: exa/firecrawl have no explicit per-runtime tool-mapping — every mcp__<server>__* except context7 rides the generic passthrough (Copilot lowercases; OpenCode/Cursor/Windsurf/Augment keep as-is; Gemini auto-discovers). tavily/ref/jina are handled identically, no install path broken. Added 12 copilot-install passthrough tests + a mcp-tool-inheritance parity guard (tavily co-declared with exa, jina with firecrawl, ref present across the 3 web researchers) so the MCP set can't drift. No io.github registry ids invented (none sourceable in-repo); documented as a follow-up. 488 tests green. Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(#657): profiles as source of truth for researcher agents + drift-guard (Phase C.3) scripts/research-profiles.cjs declares each of the 7 researcher agents' identity + contract (name, description, color, tools, required @-includes, required gsd-tools seam calls, output-contract markers). scripts/gen-research-agents.cjs --check validates every committed agent against its profile; --write regenerates ONLY the frontmatter from profiles (body untouched) and is a verified no-op against the current agents (zero diff = fidelity). tests/research-agent-profiles.test.cjs is the DEFECT.GENERATIVE-FIX drift guard. Design note: profiles govern the generatable/contract surface rather than destructively regenerating the disparate operational prose bodies (those were deduped via @-includes in Phase A). scripts/ is not inventoried (no inventory change). Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#657): complete agent provider-dispatch + parity guard; align legitimacy field; validate profiles Adversarial-review findings: (HIGH) the seam-wired agents' Step-C dispatch only mapped 6 providers, so a planResearch result of jina/ref/perplexity/brave (reachable via the waterfall fallbacks) had no handling -> agent stall; completed both agents' dispatch to all 9 PROVIDER_WATERFALL ids + a catch-all, and added a parity test asserting agent dispatch stays in sync with research-provider PROVIDER_WATERFALL (DEFECT.GENERATIVE-FIX). (MEDIUM) phase-researcher package-legitimacy JSON example used 'package' but the module returns 'name' -> aligned. (LOW) gen-research-agents checkAgent now returns a clear failure for a malformed profile instead of throwing. +parity/validation tests (RED-first). Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#656): make classifyConfidence verification-evidence-driven (W3) Confidence conflated provider authority with claim verification — context7/ref stamped HIGH purely by provider identity, and the only verification lever was a self-set --verified flag. Split into two axes: provider authority (static) + verification evidence (code-computed). HIGH now requires ground-truth corroboration (legitimacyVerdict OK), independent of provider; authority alone caps at MEDIUM; SLOP caps at LOW; the self-reported --verified is demoted to a MEDIUM-only web lever. HIGH = corroborated-against-authoritative-source, not a correctness guarantee. Adds --legitimacy-verdict to the classify-confidence CLI; updates CONTEXT.md predicate + ADR-0656 (tier set unchanged, ADR-consistent). Addresses davesienkowski's W3 review on #664. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#656): bind classify-confidence verdict to code, closing CLI self-grading Adversarial review found the new --legitimacy-verdict flag was caller-supplied, so an agent could self-assert OK->HIGH without any real legitimacy check — reintroducing the exact self-grading hole W3 closes. Remove the free flag; the CLI now computes the verdict via checkPackages only when --package/--ecosystem is given (code-computed, not agent-asserted). Update the stale CLI test (context7 alone -> MEDIUM) and extend the property test to vary legitimacyVerdict. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
640 lines
25 KiB
JavaScript
640 lines
25 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Behavioral tests for research-store.cjs
|
|
*
|
|
* No source-grep. All tests call exported functions and assert on returned objects.
|
|
*/
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const {
|
|
researchKey,
|
|
ttlForSource,
|
|
resolveStorePath,
|
|
putResearch,
|
|
getResearch,
|
|
} = require('../gsd-core/bin/lib/research-store.cjs');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cycle 2: researchKey deterministic + sensitive
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('research-store: researchKey deterministic + sensitive', () => {
|
|
const base = { ecosystem: 'npm', library: 'lodash', version: '4.17.21', query: 'chunk', kind: 'docs' };
|
|
|
|
test('same inputs produce the same key', () => {
|
|
const k1 = researchKey({ ...base });
|
|
const k2 = researchKey({ ...base });
|
|
assert.equal(k1, k2);
|
|
});
|
|
|
|
test('key is a 64-char hex sha256', () => {
|
|
const k = researchKey(base);
|
|
assert.match(k, /^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
test('changing ecosystem changes the key', () => {
|
|
assert.notEqual(researchKey({ ...base, ecosystem: 'pypi' }), researchKey(base));
|
|
});
|
|
|
|
test('changing library changes the key', () => {
|
|
assert.notEqual(researchKey({ ...base, library: 'underscore' }), researchKey(base));
|
|
});
|
|
|
|
test('changing version changes the key', () => {
|
|
assert.notEqual(researchKey({ ...base, version: '3.0.0' }), researchKey(base));
|
|
});
|
|
|
|
test('changing query changes the key', () => {
|
|
assert.notEqual(researchKey({ ...base, query: 'merge' }), researchKey(base));
|
|
});
|
|
|
|
test('changing kind changes the key', () => {
|
|
assert.notEqual(researchKey({ ...base, kind: 'web' }), researchKey(base));
|
|
});
|
|
|
|
test('never throws on arbitrary/missing inputs', () => {
|
|
assert.doesNotThrow(() => researchKey({}));
|
|
assert.doesNotThrow(() => researchKey({ ecosystem: null, library: undefined, version: 42, query: '', kind: false }));
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cycle 4: getResearch on missing key → {hit:false, stale:false, entry:null}
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('research-store: getResearch missing key', () => {
|
|
let tmpCwd;
|
|
let tmpHome;
|
|
|
|
beforeEach(() => {
|
|
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-cwd-'));
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-home-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpCwd);
|
|
cleanup(tmpHome);
|
|
});
|
|
|
|
test('returns {hit:false, stale:false, entry:null} for missing key, does not throw', () => {
|
|
assert.doesNotThrow(() => {
|
|
const result = getResearch(tmpCwd, 'nonexistentkey', { homeDir: tmpHome });
|
|
assert.equal(result.hit, false);
|
|
assert.equal(result.stale, false);
|
|
assert.equal(result.entry, null);
|
|
});
|
|
});
|
|
|
|
test('returns {hit:false} when kind omitted and key absent in both tiers', () => {
|
|
const result = getResearch(tmpCwd, 'nonexistentkey2', { homeDir: tmpHome });
|
|
assert.equal(result.hit, false);
|
|
assert.equal(result.stale, false);
|
|
assert.equal(result.entry, null);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cycle 5: getResearch on corrupt entry file → {hit:false, stale:false, entry:null}
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('research-store: getResearch corrupt file', () => {
|
|
let tmpCwd;
|
|
let tmpHome;
|
|
|
|
beforeEach(() => {
|
|
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-cwd-'));
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-home-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpCwd);
|
|
cleanup(tmpHome);
|
|
});
|
|
|
|
test('returns {hit:false, stale:false, entry:null} on corrupt JSON, does not throw', () => {
|
|
// Write garbage JSON to the expected path for a 'web' source (project tier)
|
|
const dir = resolveStorePath(tmpCwd, 'web', { homeDir: tmpHome });
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const corruptKey = 'corruptkey123';
|
|
fs.writeFileSync(path.join(dir, `${corruptKey}.json`), '{');
|
|
|
|
assert.doesNotThrow(() => {
|
|
const result = getResearch(tmpCwd, corruptKey, { homeDir: tmpHome });
|
|
assert.equal(result.hit, false);
|
|
assert.equal(result.stale, false);
|
|
assert.equal(result.entry, null);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cycle 6: ttlForSource policy — 30d / 7d / 1d
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('research-store: ttlForSource policy', () => {
|
|
const DAY_MS = 86_400_000;
|
|
|
|
test('curated + HIGH → 30 days', () => {
|
|
assert.equal(ttlForSource('curated', 'HIGH'), 30 * DAY_MS);
|
|
});
|
|
|
|
test('curated + MEDIUM → 7 days', () => {
|
|
assert.equal(ttlForSource('curated', 'MEDIUM'), 7 * DAY_MS);
|
|
});
|
|
|
|
test('web source → 1 day (regardless of confidence)', () => {
|
|
assert.equal(ttlForSource('web', 'HIGH'), DAY_MS);
|
|
});
|
|
|
|
test('confidence LOW → 1 day (regardless of source)', () => {
|
|
assert.equal(ttlForSource('curated', 'LOW'), DAY_MS);
|
|
});
|
|
|
|
test('default (unknown source + confidence) → 1 day', () => {
|
|
assert.equal(ttlForSource('unknown', 'UNKNOWN'), DAY_MS);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cycle 7: STALENESS BOUNDARY (clock seam)
|
|
// Put at clock now=0; ttl = 30d (curated/HIGH = 2592000000ms)
|
|
// now = ttl-1 → stale:false
|
|
// now = ttl → stale:false (strict >; equal is NOT stale)
|
|
// now = ttl+1 → stale:true
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('research-store: staleness boundary (clock seam)', () => {
|
|
const DAY_MS = 86_400_000;
|
|
const TTL_30D = 30 * DAY_MS;
|
|
let tmpCwd;
|
|
let tmpHome;
|
|
|
|
beforeEach(() => {
|
|
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-cwd-'));
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-home-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpCwd);
|
|
cleanup(tmpHome);
|
|
});
|
|
|
|
function putAtZero(cwd, home, key) {
|
|
const clockZero = { now: () => 0 };
|
|
// version:'4.17.21' prevents the blank-version TTL cap so TTL stays at 30d
|
|
putResearch(
|
|
cwd,
|
|
key,
|
|
{ content: 'data', source: 'curated', provider: 'p', confidence: 'HIGH', kind: 'docs', version: '4.17.21' },
|
|
{ clock: clockZero, homeDir: home }
|
|
);
|
|
}
|
|
|
|
test('now = ttl-1 → stale:false', () => {
|
|
const key = researchKey({ ecosystem: 'x', kind: 'docs', query: 'ttl-minus-1' });
|
|
putAtZero(tmpCwd, tmpHome, key);
|
|
const result = getResearch(tmpCwd, key, { clock: { now: () => TTL_30D - 1 }, homeDir: tmpHome });
|
|
assert.equal(result.hit, true);
|
|
assert.equal(result.stale, false, 'age = ttl-1 should NOT be stale');
|
|
});
|
|
|
|
test('now = ttl → stale:false (strict > boundary: equal is not stale)', () => {
|
|
const key = researchKey({ ecosystem: 'x', kind: 'docs', query: 'ttl-exact' });
|
|
putAtZero(tmpCwd, tmpHome, key);
|
|
const result = getResearch(tmpCwd, key, { clock: { now: () => TTL_30D }, homeDir: tmpHome });
|
|
assert.equal(result.hit, true);
|
|
assert.equal(result.stale, false, 'age = ttl exactly should NOT be stale (strict >)');
|
|
});
|
|
|
|
test('now = ttl+1 → stale:true', () => {
|
|
const key = researchKey({ ecosystem: 'x', kind: 'docs', query: 'ttl-plus-1' });
|
|
putAtZero(tmpCwd, tmpHome, key);
|
|
const result = getResearch(tmpCwd, key, { clock: { now: () => TTL_30D + 1 }, homeDir: tmpHome });
|
|
assert.equal(result.hit, true);
|
|
assert.equal(result.stale, true, 'age = ttl+1 should be stale');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cycle 8: resolveStorePath tiers — source-derived (I1)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('research-store: resolveStorePath tiers', () => {
|
|
const FAKE_HOME = '/fake/home';
|
|
const FAKE_CWD = '/fake/cwd';
|
|
|
|
test("source 'curated' → under injected homeDir/.gsd/research-cache", () => {
|
|
const p = resolveStorePath(FAKE_CWD, 'curated', { homeDir: FAKE_HOME });
|
|
assert.equal(p, path.join(FAKE_HOME, '.gsd', 'research-cache'));
|
|
});
|
|
|
|
test("source 'web' (project) → under cwd/.planning/research/.cache", () => {
|
|
const p = resolveStorePath(FAKE_CWD, 'web', { homeDir: FAKE_HOME });
|
|
assert.equal(p, path.join(FAKE_CWD, '.planning', 'research', '.cache'));
|
|
});
|
|
|
|
test("source 'synthesis' (project) → under cwd/.planning/research/.cache", () => {
|
|
const p = resolveStorePath(FAKE_CWD, 'synthesis', { homeDir: FAKE_HOME });
|
|
assert.equal(p, path.join(FAKE_CWD, '.planning', 'research', '.cache'));
|
|
});
|
|
|
|
test("source 'legitimacy' (project) → under cwd/.planning/research/.cache", () => {
|
|
const p = resolveStorePath(FAKE_CWD, 'legitimacy', { homeDir: FAKE_HOME });
|
|
assert.equal(p, path.join(FAKE_CWD, '.planning', 'research', '.cache'));
|
|
});
|
|
|
|
test('paths are absolute', () => {
|
|
const curated = resolveStorePath(FAKE_CWD, 'curated', { homeDir: FAKE_HOME });
|
|
const web = resolveStorePath(FAKE_CWD, 'web', { homeDir: FAKE_HOME });
|
|
assert.ok(path.isAbsolute(curated));
|
|
assert.ok(path.isAbsolute(web));
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// I1 REGRESSION: putResearch with source:'web', kind:'docs' → project tier
|
|
// (currently fails: kind:'docs' forces user tier regardless of source)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('research-store: I1 regression — source is the tier axis', () => {
|
|
let tmpCwd;
|
|
let tmpHome;
|
|
|
|
beforeEach(() => {
|
|
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-i1-cwd-'));
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-i1-home-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpCwd);
|
|
cleanup(tmpHome);
|
|
});
|
|
|
|
test('source:web + kind:docs → file in PROJECT dir, NOT user dir', () => {
|
|
const key = researchKey({ ecosystem: 'npm', library: 'axios', version: '1.0.0', query: 'get', kind: 'docs' });
|
|
putResearch(
|
|
tmpCwd,
|
|
key,
|
|
{ content: 'web data', source: 'web', provider: 'web', confidence: 'HIGH', kind: 'docs' },
|
|
{ homeDir: tmpHome }
|
|
);
|
|
|
|
const projectFile = path.join(tmpCwd, '.planning', 'research', '.cache', `${key}.json`);
|
|
const userFile = path.join(tmpHome, '.gsd', 'research-cache', `${key}.json`);
|
|
|
|
assert.ok(fs.existsSync(projectFile), 'file should exist in project dir (.planning/research/.cache)');
|
|
assert.ok(!fs.existsSync(userFile), 'file should NOT exist in user dir (~/.gsd/research-cache)');
|
|
});
|
|
|
|
test('source:curated + kind:web → file in USER dir, NOT project dir', () => {
|
|
const key = researchKey({ ecosystem: 'npm', library: 'zod', version: '3.0.0', query: 'parse', kind: 'web' });
|
|
putResearch(
|
|
tmpCwd,
|
|
key,
|
|
{ content: 'curated data', source: 'curated', provider: 'ctx7', confidence: 'HIGH', kind: 'web' },
|
|
{ homeDir: tmpHome }
|
|
);
|
|
|
|
const projectFile = path.join(tmpCwd, '.planning', 'research', '.cache', `${key}.json`);
|
|
const userFile = path.join(tmpHome, '.gsd', 'research-cache', `${key}.json`);
|
|
|
|
assert.ok(fs.existsSync(userFile), 'file should exist in user dir (~/.gsd/research-cache)');
|
|
assert.ok(!fs.existsSync(projectFile), 'file should NOT exist in project dir');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// W4 REGRESSION: getResearch prefers FRESHEST across both tiers
|
|
// (currently returns first-match regardless of staleness)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('research-store: W4a regression — prefer fresh over stale across tiers', () => {
|
|
const DAY_MS = 86_400_000;
|
|
let tmpCwd;
|
|
let tmpHome;
|
|
|
|
beforeEach(() => {
|
|
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-w4-cwd-'));
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-w4-home-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpCwd);
|
|
cleanup(tmpHome);
|
|
});
|
|
|
|
test('fresh project entry wins over stale curated entry for same key', () => {
|
|
// After fix: source derives tier. We use distinct source values so entries land in different tiers.
|
|
// We compute the key without kind so both entries share the same key.
|
|
const key = researchKey({ ecosystem: 'npm', library: 'react', version: '18.0.0', query: 'hooks' });
|
|
|
|
// Seed a STALE curated entry directly into user tier directory
|
|
const userDir = path.join(tmpHome, '.gsd', 'research-cache');
|
|
fs.mkdirSync(userDir, { recursive: true });
|
|
const staleEntry = {
|
|
content: 'stale curated content',
|
|
source: 'curated',
|
|
provider: 'ctx7',
|
|
confidence: 'HIGH',
|
|
fetched_at: new Date(0).toISOString(), // epoch → always stale at t=100d
|
|
ttl: 30 * DAY_MS,
|
|
kind: 'docs',
|
|
};
|
|
fs.writeFileSync(path.join(userDir, `${key}.json`), JSON.stringify(staleEntry));
|
|
|
|
// Seed a FRESH web entry directly into project tier directory
|
|
const freshClock = { now: () => 100 * DAY_MS }; // well beyond the curated entry's TTL
|
|
const projectDir = path.join(tmpCwd, '.planning', 'research', '.cache');
|
|
fs.mkdirSync(projectDir, { recursive: true });
|
|
const freshEntry = {
|
|
content: 'fresh web content',
|
|
source: 'web',
|
|
provider: 'web',
|
|
confidence: 'HIGH',
|
|
fetched_at: new Date(100 * DAY_MS).toISOString(), // brand new
|
|
ttl: DAY_MS,
|
|
kind: 'docs',
|
|
};
|
|
fs.writeFileSync(path.join(projectDir, `${key}.json`), JSON.stringify(freshEntry));
|
|
|
|
// Now at freshClock time, curated is stale (age=100d > 30d ttl) but web is fresh (age=0 < 1d ttl)
|
|
const result = getResearch(tmpCwd, key, { clock: freshClock, homeDir: tmpHome });
|
|
|
|
assert.equal(result.hit, true, 'should find a hit');
|
|
assert.equal(result.stale, false, 'should return the FRESH entry (stale:false)');
|
|
assert.equal(result.entry.content, 'fresh web content', 'should return fresh web content, not stale curated');
|
|
assert.equal(result.entry.source, 'web', 'source should be web');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// W4b REGRESSION: blank version caps TTL at DAY_MS
|
|
// (currently blank version still gets 30d curated TTL)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('research-store: W4b regression — blank version caps TTL', () => {
|
|
const DAY_MS = 86_400_000;
|
|
let tmpCwd;
|
|
let tmpHome;
|
|
|
|
beforeEach(() => {
|
|
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-w4b-cwd-'));
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-w4b-home-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpCwd);
|
|
cleanup(tmpHome);
|
|
});
|
|
|
|
test('curated + HIGH + version blank → ttl = DAY_MS (capped)', () => {
|
|
const key = researchKey({ ecosystem: 'npm', library: 'lodash', version: '', query: 'chunk', kind: 'docs' });
|
|
const entry = putResearch(
|
|
tmpCwd,
|
|
key,
|
|
{ content: 'data', source: 'curated', provider: 'ctx7', confidence: 'HIGH', kind: 'docs', version: '' },
|
|
{ homeDir: tmpHome }
|
|
);
|
|
assert.equal(entry.ttl, DAY_MS, 'blank version should cap TTL at DAY_MS, not 30*DAY_MS');
|
|
});
|
|
|
|
test('curated + HIGH + version "1.2.3" → ttl = 30 * DAY_MS (uncapped)', () => {
|
|
const key = researchKey({ ecosystem: 'npm', library: 'lodash', version: '1.2.3', query: 'chunk', kind: 'docs' });
|
|
const entry = putResearch(
|
|
tmpCwd,
|
|
key,
|
|
{ content: 'data', source: 'curated', provider: 'ctx7', confidence: 'HIGH', kind: 'docs', version: '1.2.3' },
|
|
{ homeDir: tmpHome }
|
|
);
|
|
assert.equal(entry.ttl, 30 * DAY_MS, 'non-blank version should NOT cap TTL');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cycle 1: TRACER BULLET — round-trip put then get
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('research-store: tracer bullet round-trip', () => {
|
|
let tmpCwd;
|
|
let tmpHome;
|
|
|
|
beforeEach(() => {
|
|
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-cwd-'));
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-home-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpCwd);
|
|
cleanup(tmpHome);
|
|
});
|
|
|
|
test('put then get returns hit:true, stale:false, entry with content preserved', () => {
|
|
const fixedClock = { now: () => 0 };
|
|
const key = researchKey({ ecosystem: 'npm', library: 'lodash', version: '4.17.21', query: 'chunk', kind: 'docs' });
|
|
|
|
const stored = putResearch(
|
|
tmpCwd,
|
|
key,
|
|
{ content: 'lodash chunk docs', source: 'curated', provider: 'npm', confidence: 'HIGH', kind: 'docs' },
|
|
{ clock: fixedClock, homeDir: tmpHome }
|
|
);
|
|
|
|
assert.equal(stored.content, 'lodash chunk docs', 'putResearch returns entry with content');
|
|
|
|
const result = getResearch(tmpCwd, key, { clock: fixedClock, homeDir: tmpHome });
|
|
|
|
assert.equal(result.hit, true, 'hit should be true');
|
|
assert.equal(result.stale, false, 'stale should be false at time 0');
|
|
assert.ok(result.entry !== null, 'entry should not be null');
|
|
assert.equal(result.entry.content, 'lodash chunk docs', 'content preserved');
|
|
assert.equal(result.entry.source, 'curated');
|
|
assert.equal(result.entry.confidence, 'HIGH');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FINDING 1 REGRESSION: key validation / path-traversal prevention
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('research-store: isValidResearchKey exported', () => {
|
|
const { isValidResearchKey } = require('../gsd-core/bin/lib/research-store.cjs');
|
|
|
|
test('isValidResearchKey is exported', () => {
|
|
assert.equal(typeof isValidResearchKey, 'function', 'isValidResearchKey must be exported');
|
|
});
|
|
|
|
test('64-char hex key is valid', () => {
|
|
assert.equal(isValidResearchKey('a'.repeat(64)), true);
|
|
assert.equal(isValidResearchKey('0123456789abcdef'.repeat(4)), true);
|
|
});
|
|
|
|
test('short key is invalid', () => {
|
|
assert.equal(isValidResearchKey('abc'), false);
|
|
});
|
|
|
|
test('traversal key is invalid', () => {
|
|
assert.equal(isValidResearchKey('../../../etc/passwd'), false);
|
|
});
|
|
|
|
test('non-hex 64-char key is invalid', () => {
|
|
assert.equal(isValidResearchKey('g'.repeat(64)), false);
|
|
});
|
|
|
|
test('non-string is invalid', () => {
|
|
assert.equal(isValidResearchKey(null), false);
|
|
assert.equal(isValidResearchKey(undefined), false);
|
|
assert.equal(isValidResearchKey(123), false);
|
|
});
|
|
});
|
|
|
|
describe('research-store: putResearch rejects traversal key', () => {
|
|
let tmpCwd;
|
|
let tmpHome;
|
|
|
|
beforeEach(() => {
|
|
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-trav-cwd-'));
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-trav-home-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpCwd);
|
|
cleanup(tmpHome);
|
|
});
|
|
|
|
test('putResearch throws on traversal key and does NOT write any file outside cache dir', () => {
|
|
const traversalKey = '../../../' + 'x'.repeat(10);
|
|
// Verify no file is created outside
|
|
const outsideTarget = path.join(os.tmpdir(), 'x'.repeat(10) + '.json');
|
|
// Remove any pre-existing file at traversal target
|
|
try { fs.unlinkSync(outsideTarget); } catch { /* ignore */ }
|
|
|
|
assert.throws(
|
|
() => putResearch(tmpCwd, traversalKey, { content: 'evil', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs' }, { homeDir: tmpHome }),
|
|
/invalid research key/i
|
|
);
|
|
assert.equal(fs.existsSync(outsideTarget), false, 'traversal target must not be created');
|
|
});
|
|
|
|
test('putResearch throws on short/fake key', () => {
|
|
assert.throws(
|
|
() => putResearch(tmpCwd, 'abc', { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs' }, { homeDir: tmpHome }),
|
|
/invalid research key/i
|
|
);
|
|
});
|
|
|
|
test('putResearch succeeds with valid 64-hex key', () => {
|
|
const key = researchKey({ ecosystem: 'npm', library: 'react', version: '18.0.0', query: 'hooks', kind: 'docs' });
|
|
assert.doesNotThrow(() => {
|
|
putResearch(tmpCwd, key, { content: 'ok', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs' }, { homeDir: tmpHome });
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('research-store: getResearch rejects traversal key', () => {
|
|
let tmpCwd;
|
|
let tmpHome;
|
|
|
|
beforeEach(() => {
|
|
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-trav2-cwd-'));
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-trav2-home-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpCwd);
|
|
cleanup(tmpHome);
|
|
});
|
|
|
|
test('getResearch returns {hit:false} on traversal key and does NOT read outside cache dir', () => {
|
|
const traversalKey = '../../../etc/passwd';
|
|
const result = getResearch(tmpCwd, traversalKey, { homeDir: tmpHome });
|
|
assert.equal(result.hit, false, 'traversal key must return hit:false');
|
|
assert.equal(result.stale, false);
|
|
assert.equal(result.entry, null);
|
|
});
|
|
|
|
test('getResearch returns {hit:false} on short key', () => {
|
|
const result = getResearch(tmpCwd, 'k1', { homeDir: tmpHome });
|
|
assert.equal(result.hit, false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FINDING 3 REGRESSION: malformed cache metadata treated as fresh
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('research-store: getResearch rejects malformed cache metadata', () => {
|
|
let tmpCwd;
|
|
let tmpHome;
|
|
|
|
beforeEach(() => {
|
|
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-malformed-cwd-'));
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-malformed-home-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpCwd);
|
|
cleanup(tmpHome);
|
|
});
|
|
|
|
function writeEntry(dir, key, entry) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, `${key}.json`), JSON.stringify(entry));
|
|
}
|
|
|
|
test('missing fetched_at → hit:false (not treated as fresh)', () => {
|
|
const key = researchKey({ ecosystem: 'npm', library: 'bad', version: '1.0.0', query: 'q', kind: 'docs' });
|
|
const dir = path.join(tmpCwd, '.planning', 'research', '.cache');
|
|
writeEntry(dir, key, { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', ttl: 86400000 });
|
|
// fetched_at is missing
|
|
const result = getResearch(tmpCwd, key, { homeDir: tmpHome });
|
|
assert.equal(result.hit, false, 'missing fetched_at must return hit:false');
|
|
});
|
|
|
|
test('ttl = "abc" (string) → hit:false', () => {
|
|
const key = researchKey({ ecosystem: 'npm', library: 'bad2', version: '1.0.0', query: 'q', kind: 'docs' });
|
|
const dir = path.join(tmpCwd, '.planning', 'research', '.cache');
|
|
writeEntry(dir, key, { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', fetched_at: new Date().toISOString(), ttl: 'abc' });
|
|
const result = getResearch(tmpCwd, key, { homeDir: tmpHome });
|
|
assert.equal(result.hit, false, 'string ttl must return hit:false');
|
|
});
|
|
|
|
test('ttl = 0 → hit:false', () => {
|
|
const key = researchKey({ ecosystem: 'npm', library: 'bad3', version: '1.0.0', query: 'q', kind: 'docs' });
|
|
const dir = path.join(tmpCwd, '.planning', 'research', '.cache');
|
|
writeEntry(dir, key, { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', fetched_at: new Date().toISOString(), ttl: 0 });
|
|
const result = getResearch(tmpCwd, key, { homeDir: tmpHome });
|
|
assert.equal(result.hit, false, 'ttl=0 must return hit:false');
|
|
});
|
|
|
|
test('ttl = -1 (negative) → hit:false', () => {
|
|
const key = researchKey({ ecosystem: 'npm', library: 'bad4', version: '1.0.0', query: 'q', kind: 'docs' });
|
|
const dir = path.join(tmpCwd, '.planning', 'research', '.cache');
|
|
writeEntry(dir, key, { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', fetched_at: new Date().toISOString(), ttl: -1 });
|
|
const result = getResearch(tmpCwd, key, { homeDir: tmpHome });
|
|
assert.equal(result.hit, false, 'negative ttl must return hit:false');
|
|
});
|
|
|
|
test('fetched_at = "not-a-date" → hit:false', () => {
|
|
const key = researchKey({ ecosystem: 'npm', library: 'bad5', version: '1.0.0', query: 'q', kind: 'docs' });
|
|
const dir = path.join(tmpCwd, '.planning', 'research', '.cache');
|
|
writeEntry(dir, key, { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', fetched_at: 'not-a-date', ttl: 86400000 });
|
|
const result = getResearch(tmpCwd, key, { homeDir: tmpHome });
|
|
assert.equal(result.hit, false, 'invalid fetched_at must return hit:false');
|
|
});
|
|
|
|
test('valid entry still works', () => {
|
|
const key = researchKey({ ecosystem: 'npm', library: 'good', version: '1.0.0', query: 'q', kind: 'docs' });
|
|
const dir = path.join(tmpCwd, '.planning', 'research', '.cache');
|
|
writeEntry(dir, key, { content: 'valid', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', fetched_at: new Date().toISOString(), ttl: 86400000 });
|
|
const result = getResearch(tmpCwd, key, { homeDir: tmpHome });
|
|
assert.equal(result.hit, true, 'valid entry should still hit');
|
|
});
|
|
});
|