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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ Each release of Research-Kit, newest first. The version is `KIT_VERSION` in

## Unreleased

- Recipe loading refuses existing files without dimensions and blank dimension labels,
naming the source and line. Decompose reports invalid recipe content with exit 2
before writing the map (#356).

- ZIP archives declare UTF-8 names in both headers, so ordinary extractors preserve non-ASCII paths declared by the package manifest (#345).

- Machine config saves preserve unpatched raw role and posture values, including typos
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 @@ -568,7 +568,7 @@ node research-kit/bin/selftest.mjs # all of it
node research-kit/bin/selftest.mjs gate hook # just these files
```

1845 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
1847 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
2 changes: 1 addition & 1 deletion research-kit/bin/decompose.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ try {
process.stderr.write(`${why}\n`);
process.exit(2);
}
if (err.code === 'UNKNOWN_RECIPE' || err.code === 'INVALID_BUDGET' || err.code === 'SEARCH_PROVIDER_NOT_READY') {
if (err.code === 'UNKNOWN_RECIPE' || err.code === 'INVALID_RECIPE' || err.code === 'INVALID_BUDGET' || err.code === 'SEARCH_PROVIDER_NOT_READY') {
process.stderr.write(`${err.message}\n`);
process.exit(2);
}
Expand Down
19 changes: 17 additions & 2 deletions research-kit/lib/decompose.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,19 @@ export function parseRecipe(text) {
let name = '';
if (match) {
let inList = false;
for (const line of match[1].split(/\r?\n/)) {
for (const [offset, line] of match[1].split(/\r?\n/).entries()) {
const named = line.match(/^name:\s*(.*)$/);
if (named) { name = named[1].trim(); continue; }
if (/^dimensions:\s*$/.test(line)) { inList = true; continue; }
if (inList) {
const item = line.match(/^\s*-\s+(.*)$/);
if (!item) { if (line.trim()) inList = false; continue; }
const [label, why] = item[1].split(/\s*\|\s*/);
if (!label.trim()) {
const err = new Error(`line ${offset + 2}: the dimension label is blank`);
err.code = 'INVALID_RECIPE';
throw err;
}
dimensions.push({ name: label.trim(), why: (why ?? '').trim() });
}
}
Expand All @@ -54,7 +59,17 @@ export function loadRecipe(nameOrPath) {
err.code = 'UNKNOWN_RECIPE';
throw err;
}
return { ...parseRecipe(text), file: direct };
let parsed;
try { parsed = parseRecipe(text); } catch (err) {
if (err.code === 'INVALID_RECIPE') err.message = `recipe "${direct}" ${err.message}`;
throw err;
}
if (!parsed.dimensions.length) {
const err = new Error(`recipe "${direct}" holds no dimensions - use a front-matter dimensions list of "- label | why" lines; see ${RECIPE_DIR}`);
err.code = 'INVALID_RECIPE';
throw err;
}
return { ...parsed, file: direct };
}

/** Where the facts probably live: the official hosts a search keeps pointing at. */
Expand Down
44 changes: 43 additions & 1 deletion research-kit/test/decompose.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Phase 0 gathers material and seeds the checklist. It contains NO judgment: it hands
// over statuses BLANK, and `subtopic-coverage` is what judges them afterwards.

import { test, describe, assert, makeProject, makePassingProject, fs } from './harness.mjs';
import { spawnSync } from 'node:child_process';
import { test, describe, assert, makeProject, makePassingProject, fs, path, KIT_ROOT } from './harness.mjs';
import { PATHS, resolve, readText, listFiles } from '../lib/core.mjs';
import { decompose, parseRecipe, loadRecipe, docsHosts, scrapeOrder, ownerOf, topicQueries, RECIPE_DIR, searchSummary, outlineOf, resolveTopic } from '../lib/decompose.mjs';
import { UNIVERSAL_DIMENSIONS, seedRows, coverageOfUniversals } from '../lib/dimensions.mjs';
Expand Down Expand Up @@ -161,6 +162,47 @@ test('an unknown recipe is an error naming where recipes live', () => {
assert.throws(() => loadRecipe('no-such-recipe'), /no recipe/);
});

const invalidRecipes = [
['plain.md', '# Just a document\n\nNo dimensions.\n', /holds no dimensions/],
['empty.md', '---\nname: empty\ndimensions:\n---\n', /holds no dimensions/],
['blank.md', '---\r\nname: blank\r\ndescription: test\r\ndimensions:\r\n - | a reason only\r\n---\r\n', /line 5.*blank/],
];

test('an existing recipe with no dimensions or a blank label is refused by file and line', () => {
const dir = makeProject();
for (const [name, text, reason] of invalidRecipes) {
const file = resolve(dir, name);
fs.writeFileSync(file, text);
assert.throws(() => loadRecipe(file), (err) => {
assert.equal(err.code, 'INVALID_RECIPE');
assert.ok(err.message.includes(file), err.message);
assert.match(err.message, reason);
return true;
});
}
});

test('invalid recipe content is CLI usage failure and leaves the map and ledger untouched', () => {
for (const [name, text, reason] of invalidRecipes) {
const dir = makePassingProject();
// Exercise recipe validation rather than the earlier refusal to replace judged rows.
fs.writeFileSync(resolve(dir, PATHS.map), '# An unclassified draft\n');
const file = resolve(dir, name);
fs.writeFileSync(file, text);
const beforeMap = readText(resolve(dir, PATHS.map));
const beforeLedger = readText(resolve(dir, PATHS.ledger));
const result = spawnSync(process.execPath, [path.join(KIT_ROOT, 'bin/decompose.mjs'),
'--recipe', file, '--dry-run', '--transport', 'http-keyless', '--search-transport', 'http-keyless'],
{ cwd: dir, encoding: 'utf8', timeout: 20000, windowsHide: true });
assert.equal(result.status, 2, result.stdout + result.stderr);
assert.match(result.stderr, reason);
assert.ok(result.stderr.includes(file), result.stderr);
assert.doesNotMatch(result.stderr, /\n\s+at /, 'usage failures must not print a stack');
assert.equal(readText(resolve(dir, PATHS.map)), beforeMap);
assert.equal(readText(resolve(dir, PATHS.ledger)), beforeLedger);
}
});

test('parseRecipe reads the machine-readable frontmatter', () => {
const { name, dimensions } = parseRecipe('---\nname: sample\ndimensions:\n - First thing | why it matters\n - Second thing | another reason\n---\n\nprose\n');
assert.equal(name, 'sample');
Expand Down
Loading