duediligence 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/api.mjs +3 -0
- package/bin/ci.mjs +13 -0
- package/bin/cli.mjs +2 -1
- package/bin/hosted.mjs +248 -0
- package/bin/push.mjs +7 -0
- package/package.json +2 -2
- package/prompts/sandbox/hidden-tests.md +73 -0
- package/skills/duediligence/SKILL.md +24 -9
package/bin/api.mjs
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
// promptPath(name) the absolute path of prompts/<name>.md; anything outside prompts/ refused
|
|
18
18
|
// renderPrompt(name, values) prompts/<name>.md with each {{key}} filled from values, as the CLI fills repo/ briefs
|
|
19
19
|
//
|
|
20
|
+
// prompts/sandbox/ holds briefs only a platform renders: hidden tests for a
|
|
21
|
+
// generated project whose cut modules are recording mocks (sandbox-factory).
|
|
22
|
+
//
|
|
20
23
|
// Suite results are what a runner prints (bin/runners/shared.mjs); the
|
|
21
24
|
// rules' inputs and outputs are documented in bin/rules.mjs, the hash's in
|
|
22
25
|
// bin/hash.mjs.
|
package/bin/ci.mjs
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// node bin/ci.mjs --seal <file|https-url> [--repo <checkout>] [--ref <rev>]
|
|
7
7
|
// [--name <check name>] [--out <payload.json>] [--record <file>]
|
|
8
8
|
// [--no-post] [--exit-zero]
|
|
9
|
+
// node bin/ci.mjs --platform [<url>] --version <id> --workspace <id> [--base <rev>]
|
|
10
|
+
// judged on the hosted platform instead (hosted.mjs)
|
|
9
11
|
//
|
|
10
12
|
// 1. A URL seal is fetched with DUEDILIGENCE_SEAL_AUTH as the
|
|
11
13
|
// Authorization header, if set, into a temporary file.
|
|
@@ -37,6 +39,17 @@ import { fileURLToPath } from "node:url";
|
|
|
37
39
|
|
|
38
40
|
const BIN = dirname(fileURLToPath(import.meta.url));
|
|
39
41
|
const argv = process.argv.slice(2);
|
|
42
|
+
|
|
43
|
+
// --platform: judged on the hosted platform instead of from a seal
|
|
44
|
+
// (hosted.mjs `ciPlatform`). A bare --platform means the default platform.
|
|
45
|
+
if (argv.includes("--platform") || argv.some((arg) => arg.startsWith("--platform="))) {
|
|
46
|
+
const { run } = await import("./hosted.mjs");
|
|
47
|
+
const { version } = JSON.parse(readFileSync(join(BIN, "..", "package.json"), "utf8"));
|
|
48
|
+
const given = argv.map((arg, index) =>
|
|
49
|
+
arg === "--platform" && (argv[index + 1] === undefined || argv[index + 1].startsWith("--")) ? "--platform=" : arg,
|
|
50
|
+
);
|
|
51
|
+
process.exit(await run("ci", given, { client: "duediligence", version }));
|
|
52
|
+
}
|
|
40
53
|
const options = {};
|
|
41
54
|
for (let i = 0; i < argv.length; i += 1) {
|
|
42
55
|
if (!argv[i].startsWith("--")) {
|
package/bin/cli.mjs
CHANGED
|
@@ -27,9 +27,10 @@ const COMMANDS = {
|
|
|
27
27
|
retry: "one retry of a blind run, told its failing scenarios",
|
|
28
28
|
"import-archive": "a calibrated spec from the experiment archive, marked not held out",
|
|
29
29
|
seal: "one spec's suites in one file, for a judge that has no specs/ (CI)",
|
|
30
|
-
ci: "judge a commit from a seal and report it as a check
|
|
30
|
+
ci: "judge a commit from a seal and report it as a check; --platform judges it on the hosted platform (OIDC)",
|
|
31
31
|
referee: "a blind referee of rejected runs' failing tests, on any backend (--backend)",
|
|
32
32
|
"red-team-audit": "red-team passes (run --brief red-team) audited: literals, a second suite, a session",
|
|
33
|
+
push: "a confirmed spec as a bounty in the signed-in platform workspace",
|
|
33
34
|
login: "sign in to the Lunox platform (device code in the browser); --org, --platform, --open",
|
|
34
35
|
whoami: "the account, workspace and token expiry signed in with",
|
|
35
36
|
logout: "delete the saved platform token",
|
package/bin/hosted.mjs
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// The commands that hand work to the hosted platform (sandbox-factory)
|
|
2
|
+
// instead of running it here, over platform-client.mjs:
|
|
3
|
+
//
|
|
4
|
+
// push <slug> a spec confirmed here becomes a bounty in the signed-in
|
|
5
|
+
// workspace: POST /api/v1/orgs/:org/bounties with the
|
|
6
|
+
// ticket's title and the confirmed spec.md as its text.
|
|
7
|
+
// Recorded in specs/<slug>/platform.json, so pushing
|
|
8
|
+
// again names the bounty already made.
|
|
9
|
+
// ci --platform a pull request judged by the platform against a
|
|
10
|
+
// published version's hidden suite, which never reaches
|
|
11
|
+
// the runner: the PR's changed files go to
|
|
12
|
+
// POST /api/judge with the run's GitHub OIDC token (no
|
|
13
|
+
// secret on either side; the platform checks the token
|
|
14
|
+
// names the repository the version was sliced from),
|
|
15
|
+
// then GET /api/judge/:id?workspace= until a verdict. The job's own
|
|
16
|
+
// pass or fail is the check.
|
|
17
|
+
//
|
|
18
|
+
// Each takes its argv and the outside world as `deps` (account.mjs's, plus
|
|
19
|
+
// `git` and `readFile`), so test/hosted.mjs drives them against a fake
|
|
20
|
+
// platform.
|
|
21
|
+
|
|
22
|
+
import { execFileSync } from "node:child_process";
|
|
23
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { join, resolve } from "node:path";
|
|
25
|
+
import { parseArgs } from "node:util";
|
|
26
|
+
|
|
27
|
+
import { defaultDeps as accountDeps } from "./account.mjs";
|
|
28
|
+
import { confirmation } from "./confirm.mjs";
|
|
29
|
+
import { specDir } from "./lib.mjs";
|
|
30
|
+
import { credentialFor, credentialsPath, platformUrl, PlatformError, request } from "./platform-client.mjs";
|
|
31
|
+
|
|
32
|
+
/** The platform's limits on one submission (sandbox-factory SUBMISSION_LIMITS). */
|
|
33
|
+
const MAX_FILES = 200;
|
|
34
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
35
|
+
const MAX_BYTES = 4_000_000;
|
|
36
|
+
/** How often a verdict is asked for, and for how long. */
|
|
37
|
+
const POLL_MS = 10_000;
|
|
38
|
+
const POLL_LIMIT_MS = 45 * 60_000;
|
|
39
|
+
const TITLE_MAX = 255;
|
|
40
|
+
const DESCRIPTION_MAX = 20_000;
|
|
41
|
+
|
|
42
|
+
export function defaultDeps() {
|
|
43
|
+
return {
|
|
44
|
+
...accountDeps(),
|
|
45
|
+
git: (args, cwd) => execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }),
|
|
46
|
+
readFile: (path) => readFileSync(path),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function options(argv, spec, usage, positionals = false) {
|
|
51
|
+
try {
|
|
52
|
+
return parseArgs({ args: argv, options: { ...spec, help: { type: "boolean", short: "h" } }, allowPositionals: positionals });
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new PlatformError(`${error.message}\n${usage}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The first `# ` heading of a markdown text, else its first line. */
|
|
59
|
+
export function titleOf(text) {
|
|
60
|
+
const heading = /^#\s+(.+)$/m.exec(text)?.[1];
|
|
61
|
+
const line = heading ?? text.split("\n").find((each) => each.trim() !== "") ?? "";
|
|
62
|
+
return line.trim().slice(0, TITLE_MAX);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `push <slug>`: a locally confirmed spec as a bounty in the signed-in workspace. */
|
|
66
|
+
export async function push(argv, { client, version }, deps) {
|
|
67
|
+
const usage = `usage: ${client} push <slug> [--platform <url>] [--again]`;
|
|
68
|
+
const { values, positionals } = options(argv, { platform: { type: "string" }, again: { type: "boolean" } }, usage, true);
|
|
69
|
+
if (values.help || positionals.length !== 1) {
|
|
70
|
+
deps.out(
|
|
71
|
+
`${usage}\n\nPosts specs/<slug>/spec.md, as confirmed (${client} confirm <slug> --by <name>), as a bounty in the workspace\n` +
|
|
72
|
+
`${client} login signed in to. The bounty's title is ticket.md's (else spec.md's) heading. Pushing again names\n` +
|
|
73
|
+
`the bounty already made (specs/<slug>/platform.json); --again posts a new one, as after editing the spec.\n`,
|
|
74
|
+
);
|
|
75
|
+
return values.help ? 0 : 1;
|
|
76
|
+
}
|
|
77
|
+
const [slug] = positionals;
|
|
78
|
+
const dir = specDir(slug);
|
|
79
|
+
const confirmed = confirmation(slug);
|
|
80
|
+
if (!confirmed.ok) throw new PlatformError(`specs/${slug}: ${confirmed.reason}. Confirm it first: ${client} confirm ${slug} --by <name>.`);
|
|
81
|
+
const base = platformUrl(values.platform, deps.env);
|
|
82
|
+
const saved = credentialFor(credentialsPath(deps.env), base, client);
|
|
83
|
+
if (saved === undefined) throw new PlatformError(`Not signed in to ${base}. Run \`${client} login\`.`);
|
|
84
|
+
const recordPath = join(dir, "platform.json");
|
|
85
|
+
const recorded = existsSync(recordPath) ? JSON.parse(readFileSync(recordPath, "utf8")) : null;
|
|
86
|
+
if (recorded !== null && !values.again && recorded.base === base && recorded.organizationId === saved.organizationId) {
|
|
87
|
+
const stale = recorded.specSha256 !== confirmed.hash;
|
|
88
|
+
deps.out(
|
|
89
|
+
`specs/${slug} is bounty ${recorded.bountyId} in @${saved.organizationHandle} (pushed ${recorded.pushedAt.slice(0, 10)}).\n` +
|
|
90
|
+
(stale ? "spec.md was confirmed again since; --again posts it as a new bounty.\n" : ""),
|
|
91
|
+
);
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
const spec = readFileSync(join(dir, "spec.md"), "utf8");
|
|
95
|
+
const ticketPath = join(dir, "ticket.md");
|
|
96
|
+
const title = titleOf(existsSync(ticketPath) ? readFileSync(ticketPath, "utf8") : spec);
|
|
97
|
+
if (title === "") throw new PlatformError(`specs/${slug}: no title in ticket.md or spec.md.`);
|
|
98
|
+
if (spec.length > DESCRIPTION_MAX)
|
|
99
|
+
throw new PlatformError(`specs/${slug}/spec.md is ${spec.length} characters; a bounty takes ${DESCRIPTION_MAX}.`);
|
|
100
|
+
const made = await request({
|
|
101
|
+
base,
|
|
102
|
+
token: saved.token,
|
|
103
|
+
client,
|
|
104
|
+
version,
|
|
105
|
+
fetchImpl: deps.fetchImpl,
|
|
106
|
+
method: "POST",
|
|
107
|
+
path: `/api/v1/orgs/${encodeURIComponent(saved.organizationId)}/bounties`,
|
|
108
|
+
body: { title, description: spec },
|
|
109
|
+
});
|
|
110
|
+
const bountyId = made?.bounty?.id;
|
|
111
|
+
if (typeof bountyId !== "string") throw new PlatformError(`${base} answered without a bounty.`);
|
|
112
|
+
writeFileSync(
|
|
113
|
+
recordPath,
|
|
114
|
+
`${JSON.stringify(
|
|
115
|
+
{
|
|
116
|
+
base,
|
|
117
|
+
organizationId: saved.organizationId,
|
|
118
|
+
bountyId,
|
|
119
|
+
specSha256: confirmed.hash,
|
|
120
|
+
confirmedBy: confirmed.record.by,
|
|
121
|
+
pushedAt: new Date(deps.now()).toISOString(),
|
|
122
|
+
},
|
|
123
|
+
null,
|
|
124
|
+
2,
|
|
125
|
+
)}\n`,
|
|
126
|
+
);
|
|
127
|
+
deps.out(
|
|
128
|
+
`Posted specs/${slug} as bounty ${bountyId} in @${saved.organizationHandle} on ${base}.\n` +
|
|
129
|
+
`Size it there, or with the MCP tool size_bounty; its decisions were confirmed here by ${confirmed.record.by}.\n`,
|
|
130
|
+
);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A GitHub Actions OIDC token for `audience`; the job needs `permissions: id-token: write`. */
|
|
135
|
+
export async function oidcToken(audience, deps) {
|
|
136
|
+
const url = deps.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
137
|
+
const bearer = deps.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
138
|
+
if (!url || !bearer)
|
|
139
|
+
throw new PlatformError(
|
|
140
|
+
"No GitHub OIDC token: run in GitHub Actions with `permissions: id-token: write` (ACTIONS_ID_TOKEN_REQUEST_URL is unset).",
|
|
141
|
+
);
|
|
142
|
+
const response = await deps.fetchImpl(`${url}${url.includes("?") ? "&" : "?"}audience=${encodeURIComponent(audience)}`, {
|
|
143
|
+
headers: { Authorization: `Bearer ${bearer}`, Accept: "application/json" },
|
|
144
|
+
signal: AbortSignal.timeout(30_000),
|
|
145
|
+
});
|
|
146
|
+
const body = await response.json().catch(() => ({}));
|
|
147
|
+
if (!response.ok || typeof body.value !== "string") throw new PlatformError(`GitHub gave no OIDC token (HTTP ${response.status}).`);
|
|
148
|
+
return body.value;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The PR's added and modified files between `base` and HEAD, whole, as the checkout has them. */
|
|
152
|
+
export function changedFiles(repo, base, deps) {
|
|
153
|
+
const names = deps
|
|
154
|
+
.git(["diff", "--name-only", "--diff-filter=AM", "-z", `${base}...HEAD`], repo)
|
|
155
|
+
.split("\0")
|
|
156
|
+
.filter((name) => name !== "");
|
|
157
|
+
if (names.length > MAX_FILES) throw new PlatformError(`The change touches ${names.length} files; the platform judges up to ${MAX_FILES}.`);
|
|
158
|
+
const files = [];
|
|
159
|
+
const skipped = [];
|
|
160
|
+
for (const path of names) {
|
|
161
|
+
const bytes = deps.readFile(resolve(repo, path));
|
|
162
|
+
if (bytes.length > MAX_FILE_BYTES || bytes.includes(0)) skipped.push(path);
|
|
163
|
+
else files.push({ path, text: bytes.toString("utf8") });
|
|
164
|
+
}
|
|
165
|
+
const total = files.reduce((sum, file) => sum + Buffer.byteLength(file.text), 0);
|
|
166
|
+
if (total > MAX_BYTES) throw new PlatformError(`The change is ${total} bytes; the platform judges up to ${MAX_BYTES}.`);
|
|
167
|
+
return { files, skipped };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const EXIT = { passed: 0, overfit: 2 };
|
|
171
|
+
|
|
172
|
+
/** `ci --platform`: the change judged on the platform; answers the exit code. */
|
|
173
|
+
export async function ciPlatform(argv, { client, version }, deps) {
|
|
174
|
+
const usage = `usage: ${client} ci --platform [<url>] --version <sandbox version id> --workspace <workspace id> [--repo <checkout>] [--base <rev>] [--exit-zero]`;
|
|
175
|
+
const { values } = options(
|
|
176
|
+
argv,
|
|
177
|
+
{
|
|
178
|
+
platform: { type: "string" },
|
|
179
|
+
version: { type: "string" },
|
|
180
|
+
workspace: { type: "string" },
|
|
181
|
+
repo: { type: "string" },
|
|
182
|
+
base: { type: "string" },
|
|
183
|
+
"exit-zero": { type: "boolean" },
|
|
184
|
+
},
|
|
185
|
+
usage,
|
|
186
|
+
);
|
|
187
|
+
if (values.help || !values.version || !values.workspace) {
|
|
188
|
+
deps.out(
|
|
189
|
+
`${usage}\n\nJudges this pull request on the platform against a published sandbox version's hidden suite, which never\n` +
|
|
190
|
+
`reaches the runner. Sends the files the PR added or changed since --base (default origin/$GITHUB_BASE_REF), with\n` +
|
|
191
|
+
`the run's GitHub OIDC token (the job needs \`permissions: id-token: write\`); the platform accepts it only from the\n` +
|
|
192
|
+
`repository the version was sliced from. Prints the verdict, counts and failing scenario keys.\n` +
|
|
193
|
+
`Exit: 0 passed, 2 overfit, 1 otherwise (0 with --exit-zero).\n`,
|
|
194
|
+
);
|
|
195
|
+
return values.help ? 0 : 1;
|
|
196
|
+
}
|
|
197
|
+
const base = platformUrl(values.platform === "" ? undefined : values.platform, deps.env);
|
|
198
|
+
const repo = resolve(values.repo ?? ".");
|
|
199
|
+
const from = values.base ?? (deps.env.GITHUB_BASE_REF ? `origin/${deps.env.GITHUB_BASE_REF}` : "HEAD~1");
|
|
200
|
+
const { files, skipped } = changedFiles(repo, from, deps);
|
|
201
|
+
for (const path of skipped) deps.err(`skipped ${path}: binary or over ${MAX_FILE_BYTES / 1024} KiB\n`);
|
|
202
|
+
if (files.length === 0) {
|
|
203
|
+
deps.out(`No file changed since ${from}; nothing to judge.\n`);
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
const call = async (options) =>
|
|
207
|
+
request({ base, token: await oidcToken(base, deps), client, version, fetchImpl: deps.fetchImpl, ...options });
|
|
208
|
+
let { submission } = await call({
|
|
209
|
+
method: "POST",
|
|
210
|
+
path: "/api/judge",
|
|
211
|
+
body: { workspace: values.workspace, versionId: values.version, files },
|
|
212
|
+
});
|
|
213
|
+
deps.out(`Submitted ${files.length} file${files.length === 1 ? "" : "s"} as ${submission.id}; waiting for the verdict...\n`);
|
|
214
|
+
const started = deps.now();
|
|
215
|
+
while (submission.status === "queued" || submission.status === "running") {
|
|
216
|
+
if (deps.now() - started > POLL_LIMIT_MS) throw new PlatformError(`No verdict for ${submission.id} after ${POLL_LIMIT_MS / 60_000} minutes.`);
|
|
217
|
+
await deps.sleep(POLL_MS);
|
|
218
|
+
({ submission } = await call({
|
|
219
|
+
path: `/api/judge/${encodeURIComponent(submission.id)}?workspace=${encodeURIComponent(values.workspace)}`,
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
const verdict = submission.status === "errored" ? "errored" : (submission.verdict ?? submission.status);
|
|
223
|
+
const counts =
|
|
224
|
+
submission.public && submission.hidden
|
|
225
|
+
? `public ${submission.public.passed}/${submission.public.total}, hidden ${submission.hidden.passed}/${submission.hidden.total}`
|
|
226
|
+
: "no tests ran";
|
|
227
|
+
const lines = [
|
|
228
|
+
`duediligence (platform): ${verdict}, ${counts}`,
|
|
229
|
+
...(submission.failedScenarios.length > 0 ? [`failing scenarios: ${submission.failedScenarios.join(", ")}`] : []),
|
|
230
|
+
...submission.refused.map((refusal) => `refused ${refusal.path}: ${refusal.reason}`),
|
|
231
|
+
];
|
|
232
|
+
deps.out(`${lines.join("\n")}\n`);
|
|
233
|
+
if (deps.env.GITHUB_STEP_SUMMARY)
|
|
234
|
+
appendFileSync(deps.env.GITHUB_STEP_SUMMARY, `### ${lines[0]}\n\n${lines.slice(1).map((line) => `- ${line}`).join("\n")}\n`);
|
|
235
|
+
if (values["exit-zero"]) return 0;
|
|
236
|
+
return EXIT[verdict] ?? 1;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Runs one, printing a PlatformError as one line on stderr. */
|
|
240
|
+
export async function run(command, argv, meta, deps = defaultDeps()) {
|
|
241
|
+
try {
|
|
242
|
+
return await (command === "push" ? push : ciPlatform)(argv, meta, deps);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (!(error instanceof PlatformError)) throw error;
|
|
245
|
+
deps.err(`${meta.client} ${command}: ${error.message}\n`);
|
|
246
|
+
return 1;
|
|
247
|
+
}
|
|
248
|
+
}
|
package/bin/push.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `duediligence push <slug>`: see hosted.mjs.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { run } from "./hosted.mjs";
|
|
5
|
+
|
|
6
|
+
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
7
|
+
process.exitCode = await run("push", process.argv.slice(2), { client: "duediligence", version });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "duediligence",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Due diligence for AI coding agents: independent, hidden test suites written from the spec. The agent works against a public suite; a hidden suite it never sees judges the result.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"due-diligence",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"skills/"
|
|
59
59
|
],
|
|
60
60
|
"scripts": {
|
|
61
|
-
"test": "node scripts/skill.mjs check && node test/api.mjs && node test/platform.mjs && node test/smoke.mjs",
|
|
61
|
+
"test": "node scripts/skill.mjs check && node test/api.mjs && node test/platform.mjs && node test/hosted.mjs && node test/smoke.mjs",
|
|
62
62
|
"version": "node scripts/skill.mjs sync && git add skills .claude-plugin",
|
|
63
63
|
"skill:check": "node scripts/skill.mjs check",
|
|
64
64
|
"skill:sync": "node scripts/skill.mjs sync",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Write the hidden tests for the bounty: the acceptance tests a developer's
|
|
2
|
+
submission is judged by, which the developer never sees. The project is a
|
|
3
|
+
standalone copy of the code the bounty changes, generated so that it
|
|
4
|
+
installs, builds, runs its walkthrough and passes its public tests. Modules
|
|
5
|
+
it does not copy are cut: each is a typed stub whose runtime values are
|
|
6
|
+
recording mocks. The public tests are what the developer iterates against;
|
|
7
|
+
these tests exist to fail a submission that passes them without doing what
|
|
8
|
+
the spec says.
|
|
9
|
+
|
|
10
|
+
Write each test under `{{hiddenDir}}/` as a `{{testPattern}}` file, and mark
|
|
11
|
+
it with the outcome it has on the project as it is now. "fail" is a test
|
|
12
|
+
that only a finished bounty passes: it checks the behaviour the bounty asks
|
|
13
|
+
for. "pass" is one that guards behaviour the code already has and a finished
|
|
14
|
+
bounty must keep. At least one must be "fail". A submission is accepted when
|
|
15
|
+
every hidden test passes, so test what the spec says through the code's own
|
|
16
|
+
interface, such that any correct implementation passes and an incomplete one
|
|
17
|
+
does not.
|
|
18
|
+
|
|
19
|
+
Cover:
|
|
20
|
+
|
|
21
|
+
- every scenario in the spec, and every scenario kind it uses (happy,
|
|
22
|
+
boundary, unhappy, and any others it tags);
|
|
23
|
+
- for each public test, at least one case it does not exercise: other
|
|
24
|
+
values, larger sizes, other orders, other combinations, more items. Name
|
|
25
|
+
the public test it extends in a comment above it;
|
|
26
|
+
- the case an implementation tuned to the public tests would get wrong. For
|
|
27
|
+
each public test, ask what shortcut passes the visible cases, and write
|
|
28
|
+
the case that shortcut fails.
|
|
29
|
+
|
|
30
|
+
Assert only what the spec states or what follows necessarily from it. Where
|
|
31
|
+
the spec, or a public test, leaves a name, message or structure open, do not
|
|
32
|
+
test it either way.
|
|
33
|
+
|
|
34
|
+
A "fail" test must fail by its assertions, not by breaking. Hidden tests are
|
|
35
|
+
compiled with the project by `{{build}}`, so they must type-check against the
|
|
36
|
+
code as it is now: a test that imports a function or passes a parameter the
|
|
37
|
+
bounty has yet to add breaks the build, and the whole baseline with it. When
|
|
38
|
+
the bounty adds something new, reach it at run time inside the test, for
|
|
39
|
+
example `const mod: Record<string, unknown> = await import("{{importExample}}")`,
|
|
40
|
+
assert that it exists, and call it through a type you declare in the test. A
|
|
41
|
+
test that throws while its file loads, or imports something that is not
|
|
42
|
+
there, is an error, never the failure it is expected to show. A "pass" is
|
|
43
|
+
read from the runner's report, not only its exit code: at least one test
|
|
44
|
+
must run to completion and none may be skipped, todo or cancelled, and a
|
|
45
|
+
file that ends early or declares no test is an error. Do not name a test
|
|
46
|
+
after a file.
|
|
47
|
+
|
|
48
|
+
Write them so that a submission can only pass by doing the work:
|
|
49
|
+
|
|
50
|
+
- Assert observable results first: what the code returns, throws or leaves
|
|
51
|
+
in the state it exposes. A test that a call fails checks that the error is
|
|
52
|
+
the one the spec describes, not just that something was thrown. Use
|
|
53
|
+
`recordedCalls()` only to confirm that a seam was reached, and with what,
|
|
54
|
+
never as the only evidence that the behaviour is there.
|
|
55
|
+
- Call `resetMocks()` at the start of each test, for example in
|
|
56
|
+
`beforeEach`, and install the behaviour each test needs inside it, so no
|
|
57
|
+
test depends on what another left behind.
|
|
58
|
+
- Include at least one case whose inputs differ from the fixtures' default
|
|
59
|
+
data and the walkthrough's: give the mocked calls other values with
|
|
60
|
+
`mockImplementation(path, fn)` inside that test, so an implementation that
|
|
61
|
+
hard-codes the public data fails. Never use the exact fixture values as a
|
|
62
|
+
test's only inputs.
|
|
63
|
+
- Each hidden test runs twice and must give the same result both times:
|
|
64
|
+
nothing may depend on timing, randomness, the clock or the order the tests
|
|
65
|
+
run in.
|
|
66
|
+
|
|
67
|
+
The toolchain is fixed: {{toolchain}} Import the project's code with
|
|
68
|
+
relative paths from the test's folder; from `{{hiddenDir}}/` a file at
|
|
69
|
+
`src/feature.ts` is `{{importExample}}`. Import
|
|
70
|
+
`{ mockImplementation, recordedCalls, resetMocks }` from `{{mockModule}}` to
|
|
71
|
+
give a mocked call behaviour or to check what the code asked of it. Packages
|
|
72
|
+
compile as `any`. Nothing may reach the network, a database or any outside
|
|
73
|
+
service.
|
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
spec, or wants to stop trusting an agent's own green test run.
|
|
11
11
|
license: MIT
|
|
12
12
|
compatibility: >-
|
|
13
|
-
Node 20.19 or later. Runs the duediligence@0.
|
|
13
|
+
Node 20.19 or later. Runs the duediligence@0.5.0 CLI from npm, which starts
|
|
14
14
|
Claude Code, Codex or Anthropic API sessions and installs its test toolchain
|
|
15
15
|
under ~/.duediligence/ once (network).
|
|
16
16
|
---
|
|
@@ -20,10 +20,10 @@ compatibility: >-
|
|
|
20
20
|
From one confirmed spec, generate a **public** suite the implementing agent
|
|
21
21
|
works against and a **hidden** suite that judges the result.
|
|
22
22
|
|
|
23
|
-
`duediligence <command>` below is the CLI, `duediligence@0.
|
|
23
|
+
`duediligence <command>` below is the CLI, `duediligence@0.5.0`. When
|
|
24
24
|
`duediligence --version` prints another version or nothing, ask the person
|
|
25
|
-
before installing it: `npm i -g duediligence@0.
|
|
26
|
-
`npx duediligence@0.
|
|
25
|
+
before installing it: `npm i -g duediligence@0.5.0`, or put
|
|
26
|
+
`npx duediligence@0.5.0` in place of `duediligence` in each command (in a clone
|
|
27
27
|
of the repository, `node bin/cli.mjs`). Run it from any folder. `specs/` and
|
|
28
28
|
`runs/` below are in the home, `~/.duediligence/` (`DUEDILIGENCE_HOME`);
|
|
29
29
|
`prompts/` and `template*/` are in the CLI's package. `duediligence where`
|
|
@@ -86,14 +86,29 @@ _Repository_ below are for that target.
|
|
|
86
86
|
When the `lunox` MCP tools are available and signed in (the Claude Code plugin
|
|
87
87
|
bundles the server; `/mcp` signs in), they work on the person's workspace on
|
|
88
88
|
the Lunox platform. Reads: `list_workspaces`, `list_bounties`, `get_bounty`,
|
|
89
|
-
`get_proposal`, `get_run`, `get_usage`. Writes, only when the person asks for
|
|
89
|
+
`get_proposal`, `get_run`, `get_usage`, `list_sandboxes`, `get_sandbox`. Writes, only when the person asks for
|
|
90
90
|
that one: `post_bounty`, `import_github_issue`, `import_jira_issue` (a new
|
|
91
|
-
bounty) and `size_bounty` (price it; this starts a paid model run).
|
|
91
|
+
bounty) and `size_bounty` (price it; this starts a paid model run).
|
|
92
|
+
`get_decisions` reads what a priced spec decided where the ticket was silent
|
|
93
|
+
(its `Decided:` items) and whether a person confirmed them; `confirm_link`
|
|
94
|
+
gives the page where the person confirms them. Hand them that link: decision
|
|
92
95
|
confirmations, approving a price and publishing happen in the platform's web
|
|
93
|
-
app
|
|
94
|
-
|
|
96
|
+
app, and you never act for them. Once the decisions are confirmed and the
|
|
97
|
+
person asks: `start_sandbox` and `prepare` (build a sandbox version; the
|
|
98
|
+
platform writes hidden tests from the confirmed spec and calibrates them
|
|
99
|
+
against a reference, as `prepare` does locally), then `get_handout` for the
|
|
100
|
+
public files and the hidden suite's commitment hash. You never see the hidden
|
|
101
|
+
tests in hosted mode: they stay on the platform. On a published version,
|
|
102
|
+
`judge` submits an implementation's changed files and `get_verdict` reads the
|
|
103
|
+
verdict (counts and failing scenario keys only); `retry` is a failed one's
|
|
104
|
+
single retry. From a terminal, `duediligence push <slug>` posts a spec
|
|
105
|
+
confirmed here as a bounty, and `duediligence ci --platform --version <id>
|
|
106
|
+
--workspace <id>` (or `action.yml` with `version` and `workspace`, job
|
|
107
|
+
permission `id-token: write`) judges a pull request on the platform. Never ask
|
|
95
108
|
for a token in the chat: the server signs in through `/mcp`, the CLI through
|
|
96
|
-
`duediligence login` in the person's own terminal.
|
|
109
|
+
`duediligence login` in the person's own terminal. A tool error with code
|
|
110
|
+
`insufficient_scope` means the sign-in predates a scope: ask the person to
|
|
111
|
+
sign in again through `/mcp`. Local mode, the steps below,
|
|
97
112
|
is unchanged and the default, and needs no sign-in.
|
|
98
113
|
|
|
99
114
|
## 0. Fit (repository)
|