Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Each release of Research-Kit, newest first. The version is `KIT_VERSION` in
`research-kit/lib/core.mjs`; a release is tagged `v<version>` (ADR-0116).

## Unreleased

- Install-state reads reject non-object records and ill-typed paths; doctor reports a named deploy warning instead of a raw path stack (#365).
- Fixed `AGENTS.md` corpus description to use evergreen relative references rather than stale hardcoded entry counts (#299).

- The suite's "every CLI refuses a flag it does not know" table now covers `measure`,
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion research-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ node research-kit/bin/selftest.mjs # all of it
node research-kit/bin/selftest.mjs gate hook # just these files
```

1789 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
1792 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
the assertions settled (ADR-0021), and each test is raced against a watchdog
(`RESEARCH_KIT_TEST_TIMEOUT`, default 60s).

Expand Down
8 changes: 5 additions & 3 deletions research-kit/lib/doctor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
posture, machineRole, collectionPolicy, readMachineConfig, retiredEnvNotes,
hooksPath, hooksPathEffective, runtimePaths, skillLocations,
readInstallState, gitVersion, isGitRepo, localHooksPathOverride, previousHooksPath, repoTopLevel, RETIRED_CONFIG_KEYS, RETIRED_EDIT_GATE_HOOKS,
EDIT_GATE_HOOK, KIT_HOME, namesHook,
EDIT_GATE_HOOK, KIT_HOME, INSTALL_STATE_PATH, namesHook,
} from './machine.mjs';

import { kitCommand } from './core.mjs';
Expand Down Expand Up @@ -542,8 +542,10 @@ export function runDoctor(root, { env = process.env, gitPaths = {}, probe = prob
// `commitGateState` reads the same way - so the measurement and the message agree.
const deployHome = installed?.kitHome ?? KIT_HOME;
const drift = deployedDrift({ env, kitHome: deployHome });
if (!installed) {
findings.push(f('warn', 'deploy', `no install state recorded - run: node ${path.join(KIT_ROOT, 'bin', 'install.mjs')}`));
if (!installed?.kitHome) {
const stateFile = env.RESEARCH_KIT_INSTALL_STATE || INSTALL_STATE_PATH;
const detail = exists(stateFile) ? `the install state at ${stateFile} is not readable` : 'no install state recorded';
findings.push(f('warn', 'deploy', `${detail} - run: node ${path.join(KIT_ROOT, 'bin', 'install.mjs')}`));
} else if (samePath(KIT_ROOT, deployHome)) {
// Found 2026-10-02 (break-test pass 3): run from the deployed copy - the copy an agent
// runs - after a line was appended to its lib/core.mjs, doctor printed "matches this
Expand Down
17 changes: 16 additions & 1 deletion research-kit/lib/machine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,22 @@ export function localHooksPathOverride(cwd = process.cwd(), opts = {}) {
// ---------------------------------------------------------------- install state

export function readInstallState(env = process.env) {
return readJson(env.RESEARCH_KIT_INSTALL_STATE || INSTALL_STATE_PATH, null);
const raw = readJson(env.RESEARCH_KIT_INSTALL_STATE || INSTALL_STATE_PATH, null);
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const state = { ...raw };
const isPath = (value) => typeof value === 'string' && value.trim().length > 0;
for (const key of ['kitHome', 'hooksPath', 'previousHooksPath', 'bound', 'deployedFrom']) {
if (!isPath(state[key])) delete state[key];
}
for (const key of ['skills', 'boundSet']) {
if (key in state) state[key] = Array.isArray(state[key]) ? state[key].filter(isPath) : [];
}
if ('skillSetHashes' in state) {
const hashes = state.skillSetHashes;
state.skillSetHashes = hashes && typeof hashes === 'object' && !Array.isArray(hashes)
? Object.fromEntries(Object.entries(hashes).filter(([, hash]) => typeof hash === 'string')) : {};
}
return state;
}

export function writeInstallState(state, env = process.env) {
Expand Down
22 changes: 22 additions & 0 deletions research-kit/test/doctor.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,28 @@ function machine({ config = {}, settings = undefined } = {}) {

const find = (findings, name) => findings.find((f) => f.name === name);

test('malformed install records produce a named deploy warning and valid CLI JSON', () => {
const root = makePassingProject();
const { dir, env } = machine({});
const gitEnv = { ...env, GIT_CONFIG_GLOBAL: path.join(dir, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' };
writeText(gitEnv.GIT_CONFIG_GLOBAL, `[core]\n hooksPath = ${JSON.stringify(path.join(KIT_ROOT, 'githooks').replace(/\\/g, '/'))}\n`);
for (const record of [{ kitHome: 42 }, { kitHome: {} }, { kitHome: true }, { kitHome: ['/x'] }, { kitHome: '' }, [], 'str']) {
writeText(env.RESEARCH_KIT_INSTALL_STATE, JSON.stringify(record));
const report = runDoctor(root, { env: gitEnv, gitPaths: { env: gitEnv }, probe: READY, record: false });
const warning = find(report.findings, 'deploy');
assert.equal(warning.severity, 'warn');
assert.ok(warning.detail.includes(env.RESEARCH_KIT_INSTALL_STATE));
assert.match(warning.detail, /not readable/);
}
writeText(env.RESEARCH_KIT_INSTALL_STATE, '{"kitHome":42}');
const result = spawnSync(process.execPath, [path.join(KIT_ROOT, 'bin', 'doctor.mjs'), '--json'], {
cwd: root, env: gitEnv, encoding: 'utf8', windowsHide: true,
});
assert.doesNotMatch(result.stderr, /ERR_INVALID_ARG_TYPE|TypeError/);
const report = JSON.parse(result.stdout);
assert.ok(report.findings.some((finding) => finding.name === 'deploy' && finding.severity === 'warn'));
});

// A no-hit result describes inspected files. Reaching the count at the final file is
// different from returning at that count while another path remains uninspected.
test('secret cap: coverage distinguishes a complete boundary from an uninspected path', () => {
Expand Down
33 changes: 32 additions & 1 deletion research-kit/test/machine.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,44 @@ import { pathToFileURL } from 'node:url';
import { test, describe, assert, tempDir, fs, path, os, KIT_ROOT } from './harness.mjs';
import { writeText, readText, homeDir } from '../lib/core.mjs';
import {
readMachineConfig, loadConfig, saveConfig, posture, machineRole, collectionPolicy,
readMachineConfig, loadConfig, saveConfig, posture, machineRole, collectionPolicy, readInstallState,
collectionRefusal, evidencePolicy, runtimePaths, skillLocations, retiredEnvNotes,
RUNTIME_ANCHORS, RETIRED_ENV_VARS, RETIRED_CONFIG_KEYS, DEFAULTS, ROLES,
} from '../lib/machine.mjs';

describe('machine');

test('install state refuses JSON that is not an object', () => {
const file = path.join(tempDir('rk-state-shape-'), 'install.json');
const env = { RESEARCH_KIT_INSTALL_STATE: file };
for (const value of [null, [], 'str', true, 42]) {
writeText(file, JSON.stringify(value));
assert.equal(readInstallState(env), null, JSON.stringify(value));
}
});

test('install state keeps typed paths, arrays and skill hashes without changing the file', () => {
const file = path.join(tempDir('rk-state-fields-'), 'install.json');
const env = { RESEARCH_KIT_INSTALL_STATE: file };
const paths = ['kitHome', 'hooksPath', 'previousHooksPath', 'bound', 'deployedFrom'];
for (const value of [42, true, {}, ['/x'], '', ' ']) {
writeText(file, JSON.stringify(Object.fromEntries(paths.map((key) => [key, value]))));
const state = readInstallState(env);
for (const key of paths) assert.equal(state[key], undefined, `${key}: ${JSON.stringify(value)}`);
}
const raw = { ...Object.fromEntries(paths.map((key) => [key, '/valid/path'])), skills: ['/a', 42, '', '/b'],
boundSet: ['/c', false, null], skillSetHashes: { '/a': 'hash', '/b': 42 }, at: 'saved-time' };
writeText(file, JSON.stringify(raw));
const before = readText(file);
const state = readInstallState(env);
for (const key of paths) assert.equal(state[key], raw[key]);
assert.deepEqual(state.skills, ['/a', '/b']);
assert.deepEqual(state.boundSet, ['/c']);
assert.deepEqual(state.skillSetHashes, { '/a': 'hash' });
assert.equal(state.at, 'saved-time');
assert.equal(readText(file), before);
});

function envWith(contents, { snapshot = null, extra = {} } = {}) {
const dir = tempDir('research-kit-config-');
const file = path.join(dir, 'research-kit.config.json');
Expand Down
Loading