unitbob 0.2.4 → 0.2.7
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/dist/bin.js +11 -0
- package/dist/cli.js +20 -18
- package/dist/files/behavioral.js +45 -1
- package/dist/files/suiteBuild.js +240 -15
- package/dist/verbs/putSuiteBuild.js +25 -2
- package/dist/verbs/suitePrepare.js +35 -5
- package/dist/verbs/suiteReviewPrepare.js +108 -0
- package/package.json +3 -3
package/dist/bin.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The published executable (`package.json` bin). It exists only to start the
|
|
3
|
+
// process and end it: no logic lives here, so nothing about how the CLI was
|
|
4
|
+
// invoked can change what it does. npm installs a bin as a symlink, so this file
|
|
5
|
+
// runs under a path that is not its own — an entry point that tried to detect
|
|
6
|
+
// "am I the main module?" silently did nothing once installed.
|
|
7
|
+
import { main } from "./cli.js";
|
|
8
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
|
|
9
|
+
process.stderr.write(`${err.message}\n`);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
});
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Verb dispatch. Parse `unitbob <verb> [args]`, dispatch to a hands-verb, and map
|
|
2
|
+
// any thrown error to an exit code with an actionable message — never a raw stack
|
|
3
|
+
// trace. This module decides exit codes but never acts on them: importing it must
|
|
4
|
+
// stay free of side effects so tests can drive `main` directly. `bin.ts` is the
|
|
5
|
+
// executable that owns process startup and exit.
|
|
5
6
|
import { ensureLinked } from "./link.js";
|
|
6
7
|
import { recipe } from "./verbs/recipe.js";
|
|
7
8
|
import { show } from "./verbs/show.js";
|
|
@@ -13,6 +14,7 @@ import { suitePrepare } from "./verbs/suitePrepare.js";
|
|
|
13
14
|
import { putSuiteBuild } from "./verbs/putSuiteBuild.js";
|
|
14
15
|
import { fixPrepare } from "./verbs/fixPrepare.js";
|
|
15
16
|
import { contractPrompt } from "./verbs/contractPrompt.js";
|
|
17
|
+
import { suiteReviewPrepare } from "./verbs/suiteReviewPrepare.js";
|
|
16
18
|
const USAGE = `unitbob — thin local hands for the Unitbob server.
|
|
17
19
|
|
|
18
20
|
Usage: unitbob <verb> [args]
|
|
@@ -24,6 +26,7 @@ Verbs:
|
|
|
24
26
|
map-prepare Internal: keylessly update the graph (no API key) and write the host map-build request.
|
|
25
27
|
put-map-build Internal: upload the host-built map and graph.
|
|
26
28
|
suite-prepare Internal: fetch the recipe and capability assignment, write the host suite-build request.
|
|
29
|
+
suite-review-prepare Internal: bind an independent BDD quality review to the built behavioral candidate.
|
|
27
30
|
put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata).
|
|
28
31
|
fix-prepare <id> Internal: fetch the per-capability repair packet for one red guard (by interface_id).
|
|
29
32
|
contract-prompt <digest> <test_id> [fix|accept]
|
|
@@ -40,7 +43,7 @@ host-LLM's job; any semantic graph enrichment is host-LLM work (the /graphify sk
|
|
|
40
43
|
|
|
41
44
|
Config: .unitbob.json at your project root, created automatically: the first
|
|
42
45
|
run registers the project on the server by its folder name (spec 28).`;
|
|
43
|
-
async function main(argv) {
|
|
46
|
+
export async function main(argv, deps = { ensureLinked }) {
|
|
44
47
|
const [verb, ...args] = argv;
|
|
45
48
|
if (!verb || verb === '--help' || verb === '-h' || verb === 'help') {
|
|
46
49
|
process.stdout.write(`${USAGE}\n`);
|
|
@@ -52,32 +55,35 @@ async function main(argv) {
|
|
|
52
55
|
await init(args);
|
|
53
56
|
return 0;
|
|
54
57
|
case 'recipe':
|
|
55
|
-
await recipe(await ensureLinked(), args);
|
|
58
|
+
await recipe(await deps.ensureLinked(), args);
|
|
56
59
|
return 0;
|
|
57
60
|
case 'show':
|
|
58
|
-
await show(await ensureLinked());
|
|
61
|
+
await show(await deps.ensureLinked());
|
|
59
62
|
return 0;
|
|
60
63
|
case 'map-prepare':
|
|
61
|
-
await mapPrepare(await ensureLinked(), args);
|
|
64
|
+
await mapPrepare(await deps.ensureLinked(), args);
|
|
62
65
|
return 0;
|
|
63
66
|
case 'put-map-build':
|
|
64
|
-
await putMapBuild(await ensureLinked(), args);
|
|
67
|
+
await putMapBuild(await deps.ensureLinked(), args);
|
|
65
68
|
return 0;
|
|
66
69
|
case 'suite-prepare':
|
|
67
|
-
await suitePrepare(await ensureLinked(), args);
|
|
70
|
+
await suitePrepare(await deps.ensureLinked(), args);
|
|
71
|
+
return 0;
|
|
72
|
+
case 'suite-review-prepare':
|
|
73
|
+
await suiteReviewPrepare(await deps.ensureLinked(), args);
|
|
68
74
|
return 0;
|
|
69
75
|
case 'put-suite-build':
|
|
70
|
-
await putSuiteBuild(await ensureLinked(), args);
|
|
76
|
+
await putSuiteBuild(await deps.ensureLinked(), args);
|
|
71
77
|
return 0;
|
|
72
78
|
case 'fix-prepare':
|
|
73
|
-
await fixPrepare(await ensureLinked(), args);
|
|
79
|
+
await fixPrepare(await deps.ensureLinked(), args);
|
|
74
80
|
return 0;
|
|
75
81
|
case 'contract-prompt':
|
|
76
|
-
await contractPrompt(await ensureLinked(), args);
|
|
82
|
+
await contractPrompt(await deps.ensureLinked(), args);
|
|
77
83
|
return 0;
|
|
78
84
|
case 'run':
|
|
79
85
|
case 'check':
|
|
80
|
-
await run(await ensureLinked(), args);
|
|
86
|
+
await run(await deps.ensureLinked(), args);
|
|
81
87
|
return 0;
|
|
82
88
|
default:
|
|
83
89
|
process.stderr.write(`Unknown verb "${verb}".\n\n${USAGE}\n`);
|
|
@@ -89,7 +95,3 @@ async function main(argv) {
|
|
|
89
95
|
return 1;
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
|
-
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
|
|
93
|
-
process.stderr.write(`${err.message}\n`);
|
|
94
|
-
process.exit(1);
|
|
95
|
-
});
|
package/dist/files/behavioral.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { assertUnitbobPath } from "./artifactPath.js";
|
|
4
4
|
// The behavioral suite lives under its own root: the main `.feature` plus its
|
|
@@ -34,6 +34,50 @@ export function materializeBehavioral(projectRoot, artifact, runner) {
|
|
|
34
34
|
}
|
|
35
35
|
return { mainPath };
|
|
36
36
|
}
|
|
37
|
+
// Everything under the behavioral root that the next materialization will
|
|
38
|
+
// delete: it wipes every top-level entry outside the runner environment and
|
|
39
|
+
// writes back only the files the answer listed, so a step file the answer forgot
|
|
40
|
+
// is gone and its steps come back undefined — a harness break reported far from
|
|
41
|
+
// its cause. One file per capability makes forgetting one much easier than a
|
|
42
|
+
// single file for the whole product ever did.
|
|
43
|
+
//
|
|
44
|
+
// The whole root is walked, not just the directories the answer happens to use:
|
|
45
|
+
// the file most likely to be forgotten is the one in a directory the answer
|
|
46
|
+
// never mentions — `features/support/env.rb` is exactly that shape.
|
|
47
|
+
export function filesLostOnMaterialize(projectRoot, artifact, runner) {
|
|
48
|
+
const behavioralRoot = join(projectRoot, BEHAVIORAL_DIR);
|
|
49
|
+
if (!existsSync(behavioralRoot))
|
|
50
|
+
return [];
|
|
51
|
+
const listed = new Set([artifact.path, ...(artifact.support_files ?? []).map((file) => file.path)]);
|
|
52
|
+
const runnerEntries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
|
|
53
|
+
return readdirSync(behavioralRoot)
|
|
54
|
+
.filter((entry) => !runnerEntries.has(entry))
|
|
55
|
+
.flatMap((entry) => filesUnder(projectRoot, `${BEHAVIORAL_DIR}/${entry}`))
|
|
56
|
+
.filter((path) => !listed.has(path))
|
|
57
|
+
.sort();
|
|
58
|
+
}
|
|
59
|
+
// The real files under `relative`. Symlinks are not followed: materialization
|
|
60
|
+
// removes the link, not what it points at, and naming the target here would read
|
|
61
|
+
// as a warning about a file that was never in danger.
|
|
62
|
+
function filesUnder(projectRoot, relative) {
|
|
63
|
+
const stats = lstatSync(join(projectRoot, relative));
|
|
64
|
+
if (stats.isFile())
|
|
65
|
+
return [relative];
|
|
66
|
+
if (!stats.isDirectory())
|
|
67
|
+
return [];
|
|
68
|
+
return readdirSync(join(projectRoot, relative)).flatMap((entry) => filesUnder(projectRoot, `${relative}/${entry}`));
|
|
69
|
+
}
|
|
70
|
+
export function copyBehavioralRunnerEnvironment(sourceRoot, targetRoot, runner) {
|
|
71
|
+
const entries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
const source = join(sourceRoot, BEHAVIORAL_DIR, entry);
|
|
74
|
+
if (!existsSync(source))
|
|
75
|
+
continue;
|
|
76
|
+
const target = join(targetRoot, BEHAVIORAL_DIR, entry);
|
|
77
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
78
|
+
cpSync(source, target, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
37
81
|
const EMPTY_ENTRIES = new Set();
|
|
38
82
|
const RUNNER_ENVIRONMENT_ENTRIES = {
|
|
39
83
|
cucumber: new Set(['.bundle', 'Gemfile', 'Gemfile.lock']),
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { dirname, join, sep } from 'node:path';
|
|
3
4
|
import { assertUnitbobPath } from "./artifactPath.js";
|
|
4
5
|
export function requestPath(projectRoot) {
|
|
5
6
|
return join(projectRoot, '.unitbob', 'suite-build', 'request.json');
|
|
@@ -7,11 +8,170 @@ export function requestPath(projectRoot) {
|
|
|
7
8
|
export function outputPath(projectRoot) {
|
|
8
9
|
return join(projectRoot, '.unitbob', 'suite-build', 'suite_output.json');
|
|
9
10
|
}
|
|
10
|
-
export function
|
|
11
|
+
export function reviewOutputPath(projectRoot) {
|
|
12
|
+
return join(projectRoot, '.unitbob', 'suite-build', 'behavioral_review.json');
|
|
13
|
+
}
|
|
14
|
+
export function reviewRequestPath(projectRoot) {
|
|
15
|
+
return join(projectRoot, '.unitbob', 'suite-build', 'review-request.json');
|
|
16
|
+
}
|
|
17
|
+
export function candidateRunPath(projectRoot) {
|
|
18
|
+
return join(projectRoot, '.unitbob', 'suite-build', 'candidate-run.json');
|
|
19
|
+
}
|
|
20
|
+
export function writeBehavioralReviewRequest(projectRoot, output, candidateRun, knownDefectContext = { status: 'not_supplied' }, fixedCandidateRun) {
|
|
21
|
+
const metadata = output.test_metadata;
|
|
22
|
+
const candidateDigest = suiteCandidateDigest(output);
|
|
23
|
+
const evidence = {
|
|
24
|
+
candidate_digest: candidateDigest,
|
|
25
|
+
known_defect_context: knownDefectContext,
|
|
26
|
+
candidate_run: { candidate_digest: candidateDigest, ...candidateRun },
|
|
27
|
+
...(fixedCandidateRun ? {
|
|
28
|
+
fixed_candidate_run: { candidate_digest: candidateDigest, ...fixedCandidateRun },
|
|
29
|
+
} : {}),
|
|
30
|
+
};
|
|
31
|
+
writeArtifact(candidateRunPath(projectRoot), evidence);
|
|
32
|
+
const request = {
|
|
33
|
+
candidate_digest: candidateDigest,
|
|
34
|
+
suite_file: output.suite_file,
|
|
35
|
+
capabilities: metadata?.capabilities,
|
|
36
|
+
output_path: reviewOutputPath(projectRoot),
|
|
37
|
+
known_defect_context: knownDefectContext,
|
|
38
|
+
// Only a supplied defect gives the reviewer something to read a run for.
|
|
39
|
+
...(knownDefectContext.status === 'supplied' ? {
|
|
40
|
+
candidate_run: evidence.candidate_run,
|
|
41
|
+
...(evidence.fixed_candidate_run ? { fixed_candidate_run: evidence.fixed_candidate_run } : {}),
|
|
42
|
+
} : {}),
|
|
43
|
+
};
|
|
44
|
+
writeArtifact(reviewRequestPath(projectRoot), request);
|
|
45
|
+
return request;
|
|
46
|
+
}
|
|
47
|
+
function writeArtifact(path, value) {
|
|
48
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
49
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
50
|
+
}
|
|
51
|
+
// The connector-owned runner strategy this branch selected. Reading it out of
|
|
52
|
+
// the envelope is transport, like every other `runner_manifest` access in this
|
|
53
|
+
// module — callers only ever get back the strategy name they dispatch on.
|
|
54
|
+
export function branchRunner(output) {
|
|
55
|
+
const manifest = output.runner_manifest;
|
|
56
|
+
const runner = manifest?.runner;
|
|
57
|
+
if (typeof runner !== 'string' || !runner) {
|
|
58
|
+
throw new Error('The behavioral candidate names no runner strategy to execute.');
|
|
59
|
+
}
|
|
60
|
+
return runner;
|
|
61
|
+
}
|
|
62
|
+
export function suiteCandidateDigest(output) {
|
|
63
|
+
return createHash('sha256')
|
|
64
|
+
.update(stableJson({
|
|
65
|
+
suite_file: output.suite_file,
|
|
66
|
+
runner_manifest: output.runner_manifest,
|
|
67
|
+
test_metadata: output.test_metadata,
|
|
68
|
+
}))
|
|
69
|
+
.digest('hex');
|
|
70
|
+
}
|
|
71
|
+
// Everything the server strips back out before it recomputes the candidate
|
|
72
|
+
// digest. The generator owns none of it, and if it writes any of these keys the
|
|
73
|
+
// two sides hash different objects — which surfaces as "this review is about a
|
|
74
|
+
// different candidate", blaming the reviewer for the generator's mistake. Refuse
|
|
75
|
+
// it here, where the real cause can still be named.
|
|
76
|
+
const POST_CANDIDATE_METADATA_KEYS = [
|
|
77
|
+
'bdd_quality_review',
|
|
78
|
+
'known_defect_probe',
|
|
79
|
+
'known_defect_context',
|
|
80
|
+
'candidate_run',
|
|
81
|
+
'fixed_candidate_run',
|
|
82
|
+
];
|
|
83
|
+
export function readBehavioralReview(projectRoot, output) {
|
|
84
|
+
const metadata = output.test_metadata;
|
|
85
|
+
const embedded = metadata ? POST_CANDIDATE_METADATA_KEYS.filter((key) => key in metadata) : [];
|
|
86
|
+
if (embedded.length > 0) {
|
|
87
|
+
throw new Error(`The behavioral suite generator must not embed its own review (found ${embedded.join(', ')} in test_metadata); run \`unitbob suite-review-prepare\` and provide the separate review artifact.`);
|
|
88
|
+
}
|
|
89
|
+
const path = reviewOutputPath(projectRoot);
|
|
90
|
+
if (!existsSync(path)) {
|
|
91
|
+
throw new Error(`${path} not found — run \`unitbob suite-review-prepare\` and have an independent reviewer write it.`);
|
|
92
|
+
}
|
|
93
|
+
const review = parseJson(readFileSync(path, 'utf8'), path);
|
|
94
|
+
if (!review || review.candidate_digest !== suiteCandidateDigest(output)) {
|
|
95
|
+
throw new Error(`${path} ${staleReviewReason(projectRoot, review, output)}`);
|
|
96
|
+
}
|
|
97
|
+
if (!('bdd_quality_review' in review) || !('known_defect_probe' in review)) {
|
|
98
|
+
throw new Error(`${path} must contain bdd_quality_review and known_defect_probe.`);
|
|
99
|
+
}
|
|
100
|
+
const evidence = readCandidateRunEvidence(projectRoot, output);
|
|
101
|
+
return {
|
|
102
|
+
...review,
|
|
103
|
+
candidate_run: evidence.candidate_run,
|
|
104
|
+
...(evidence.fixed_candidate_run ? { fixed_candidate_run: evidence.fixed_candidate_run } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
// Why a review does not bind — two different mistakes with one symptom.
|
|
108
|
+
//
|
|
109
|
+
// The likelier one, now that a branch may answer with a bare `{ path }`: the
|
|
110
|
+
// review was right when it was written, and the candidate changed afterwards.
|
|
111
|
+
// Editing one step file is enough, and nothing in the answer has to change for
|
|
112
|
+
// it — so the digest moves with no visible cause, and "this does not review the
|
|
113
|
+
// current candidate" sends the reader off to audit the reviewer instead of their
|
|
114
|
+
// own last edit.
|
|
115
|
+
//
|
|
116
|
+
// The connector's own evidence file settles which mistake it was: it carries the
|
|
117
|
+
// digest of the candidate that was actually run at review time. Agreeing with
|
|
118
|
+
// the review and disagreeing with what is on disk now means the candidate moved
|
|
119
|
+
// after the review. Otherwise the review really is about a different candidate.
|
|
120
|
+
function staleReviewReason(projectRoot, review, output) {
|
|
121
|
+
const evidencePath = candidateRunPath(projectRoot);
|
|
122
|
+
if (review && existsSync(evidencePath)) {
|
|
123
|
+
const evidence = parseJson(readFileSync(evidencePath, 'utf8'), evidencePath);
|
|
124
|
+
if (evidence && evidence.candidate_digest === review.candidate_digest) {
|
|
125
|
+
return 'reviewed this suite as it stood at review time, and it has changed since — re-run ' +
|
|
126
|
+
'`unitbob suite-review-prepare` and have the reviewer look at the changed suite.';
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return 'does not review the current behavioral suite candidate.';
|
|
130
|
+
}
|
|
131
|
+
// The runs the connector made of this exact candidate, and the defect choice
|
|
132
|
+
// they were made under. One file, read on its own: the evidence the upload
|
|
133
|
+
// carries must not depend on what the reviewer was shown, and reaching back into
|
|
134
|
+
// the reviewer's request for the defect choice would have re-created exactly
|
|
135
|
+
// that dependency.
|
|
136
|
+
function readCandidateRunEvidence(projectRoot, output) {
|
|
137
|
+
const path = candidateRunPath(projectRoot);
|
|
138
|
+
if (!existsSync(path)) {
|
|
139
|
+
throw new Error(`${path} not found — run \`unitbob suite-review-prepare\` to record the candidate's run.`);
|
|
140
|
+
}
|
|
141
|
+
const file = parseJson(readFileSync(path, 'utf8'), path);
|
|
142
|
+
const digest = suiteCandidateDigest(output);
|
|
143
|
+
if (!file || file.candidate_digest !== digest || !isRunEvidence(file.candidate_run, digest)) {
|
|
144
|
+
throw new Error(`${path} has no connector runner evidence for the current behavioral candidate.`);
|
|
145
|
+
}
|
|
146
|
+
// Demanded, not defaulted. `readKnownDefectContext` reads a missing field as
|
|
147
|
+
// `not_supplied`, which is right for a file someone else may have written and
|
|
148
|
+
// wrong here: the connector writes this one itself, always with the choice it
|
|
149
|
+
// was given. Missing means the file is damaged, and taking it as "no defect
|
|
150
|
+
// was supplied" would turn that into a silently skipped fixed-revision check.
|
|
151
|
+
if (file.known_defect_context === undefined) {
|
|
152
|
+
throw new Error(`${path} records no known_defect_context — run \`unitbob suite-review-prepare\` again.`);
|
|
153
|
+
}
|
|
154
|
+
const context = readKnownDefectContext(file.known_defect_context, path);
|
|
155
|
+
if (context.status === 'supplied' && context.fixed_revision) {
|
|
156
|
+
const fixed = file.fixed_candidate_run;
|
|
157
|
+
if (!isRunEvidence(fixed, digest) || fixed?.revision !== context.fixed_revision) {
|
|
158
|
+
throw new Error(`${path} has no connector runner evidence for fixed revision ${context.fixed_revision}.`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return file;
|
|
162
|
+
}
|
|
163
|
+
function isRunEvidence(value, digest) {
|
|
164
|
+
const run = value;
|
|
165
|
+
return Boolean(run && run.candidate_digest === digest &&
|
|
166
|
+
typeof run.revision === 'string' && run.revision &&
|
|
167
|
+
typeof run.run_result === 'string' && run.run_result);
|
|
168
|
+
}
|
|
169
|
+
export function writeSuiteBuildRequest(projectRoot, branches, knownDefectContext = { status: 'not_supplied' }) {
|
|
11
170
|
const request = {
|
|
12
171
|
project_root: projectRoot,
|
|
13
172
|
output_path: outputPath(projectRoot),
|
|
14
173
|
branches,
|
|
174
|
+
known_defect_context: knownDefectContext,
|
|
15
175
|
};
|
|
16
176
|
const path = requestPath(projectRoot);
|
|
17
177
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -30,7 +190,31 @@ export function readSuiteBuildRequest(projectRoot) {
|
|
|
30
190
|
!Array.isArray(request.branches)) {
|
|
31
191
|
throw new Error(`${path} is malformed: expected project_root, output_path, and a branches array.`);
|
|
32
192
|
}
|
|
33
|
-
return
|
|
193
|
+
return {
|
|
194
|
+
...request,
|
|
195
|
+
known_defect_context: readKnownDefectContext(request.known_defect_context, path),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function readKnownDefectContext(value, path) {
|
|
199
|
+
if (value === undefined)
|
|
200
|
+
return { status: 'not_supplied' };
|
|
201
|
+
if (!value || typeof value !== 'object')
|
|
202
|
+
throw new Error(`${path}: known_defect_context must be an object.`);
|
|
203
|
+
const context = value;
|
|
204
|
+
if (context.status === 'not_supplied')
|
|
205
|
+
return { status: 'not_supplied' };
|
|
206
|
+
if (context.status !== 'supplied' || typeof context.defect !== 'string' || !context.defect.trim()) {
|
|
207
|
+
throw new Error(`${path}: supplied known_defect_context must name a defect.`);
|
|
208
|
+
}
|
|
209
|
+
if (context.fixed_revision !== undefined &&
|
|
210
|
+
(typeof context.fixed_revision !== 'string' || !context.fixed_revision.trim())) {
|
|
211
|
+
throw new Error(`${path}: fixed_revision must be a non-empty string.`);
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
status: 'supplied',
|
|
215
|
+
defect: context.defect,
|
|
216
|
+
...(context.fixed_revision ? { fixed_revision: context.fixed_revision } : {}),
|
|
217
|
+
};
|
|
34
218
|
}
|
|
35
219
|
// Read the host's answers, one per branch. The connector verifies each built
|
|
36
220
|
// branch parses, carries a safe-path artifact envelope under its own root, and
|
|
@@ -47,9 +231,9 @@ export function readHostSuiteOutputs(path, request) {
|
|
|
47
231
|
throw new Error(`${path} is malformed: expected a branches array, one entry per contract system.`);
|
|
48
232
|
}
|
|
49
233
|
const rootFor = new Map(request.branches.map((branch) => [branch.suite_kind, branch.path_root]));
|
|
50
|
-
return branches.map((entry) => readBranch(entry, rootFor, path));
|
|
234
|
+
return branches.map((entry) => readBranch(entry, rootFor, path, request.project_root));
|
|
51
235
|
}
|
|
52
|
-
function readBranch(entry, rootFor, path) {
|
|
236
|
+
function readBranch(entry, rootFor, path, projectRoot) {
|
|
53
237
|
if (!entry || typeof entry !== 'object') {
|
|
54
238
|
throw new Error(`${path} is malformed: each branch must be an object.`);
|
|
55
239
|
}
|
|
@@ -75,32 +259,64 @@ function readBranch(entry, rootFor, path) {
|
|
|
75
259
|
}
|
|
76
260
|
return {
|
|
77
261
|
suite_kind: suiteKind,
|
|
78
|
-
suite_file: resolveSuiteFile(branch.suite_file, root, path, suiteKind),
|
|
262
|
+
suite_file: resolveSuiteFile(branch.suite_file, root, path, suiteKind, projectRoot),
|
|
79
263
|
runner_manifest: manifest,
|
|
80
264
|
test_metadata: branch.test_metadata,
|
|
81
265
|
};
|
|
82
266
|
}
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
267
|
+
// Each path is checked safe under this branch's root before anything is
|
|
268
|
+
// accepted. `content` may be inlined, but it does not have to be: the host wrote
|
|
269
|
+
// and ran these files on disk before answering, so a bare `{ path }` means "the
|
|
270
|
+
// file already under that path is the answer". Re-serializing a whole suite into
|
|
271
|
+
// this JSON on every rebuild was the single largest cost in the build loop, and
|
|
272
|
+
// the copy was never more trustworthy than the file that was actually executed.
|
|
273
|
+
function resolveSuiteFile(file, root, path, suiteKind, projectRoot) {
|
|
86
274
|
if (!file || typeof file !== 'object') {
|
|
87
275
|
throw new Error(`${path}: the ${suiteKind} branch is missing suite_file.`);
|
|
88
276
|
}
|
|
89
277
|
const envelope = file;
|
|
90
|
-
const main = readOneFile(envelope, root, path, suiteKind, false);
|
|
278
|
+
const main = readOneFile(envelope, root, path, suiteKind, false, projectRoot);
|
|
91
279
|
const support = Array.isArray(envelope.support_files)
|
|
92
|
-
? envelope.support_files.map((entry) => readOneFile(entry, root, path, suiteKind, true))
|
|
280
|
+
? envelope.support_files.map((entry) => readOneFile(entry, root, path, suiteKind, true, projectRoot))
|
|
93
281
|
: [];
|
|
94
282
|
return support.length > 0 ? { ...main, support_files: support } : main;
|
|
95
283
|
}
|
|
96
|
-
function readOneFile(file, root, path, suiteKind, support) {
|
|
284
|
+
function readOneFile(file, root, path, suiteKind, support, projectRoot) {
|
|
97
285
|
const filePath = typeof file.path === 'string' ? file.path : '';
|
|
98
286
|
assertUnitbobPath(filePath, root);
|
|
99
287
|
if (typeof file.content === 'string' && file.content.trim()) {
|
|
100
288
|
return { path: filePath, content: file.content };
|
|
101
289
|
}
|
|
102
|
-
|
|
103
|
-
|
|
290
|
+
if (file.content !== undefined) {
|
|
291
|
+
throw new Error(`${path}: the ${suiteKind} ${fileLabel(support)} at "${filePath}" has empty content.`);
|
|
292
|
+
}
|
|
293
|
+
const onDisk = join(projectRoot, filePath);
|
|
294
|
+
if (!existsSync(onDisk)) {
|
|
295
|
+
throw new Error(`${path}: the ${suiteKind} ${fileLabel(support)} names "${filePath}", but no such file exists — write it before answering, or inline its content.`);
|
|
296
|
+
}
|
|
297
|
+
// `assertUnitbobPath` judges the path text; it cannot see that a safe-looking
|
|
298
|
+
// name is a link out of the suite root — nor that the directory holding it is.
|
|
299
|
+
// Inlined content could never reach outside the answer, so reading from disk
|
|
300
|
+
// is where that check has to be made: these bytes are uploaded.
|
|
301
|
+
//
|
|
302
|
+
// Only the project root is resolved, and the suite root is then joined onto it
|
|
303
|
+
// as text. Resolving the suite root too would let a linked `.unitbob/<kind>/`
|
|
304
|
+
// vouch for itself — every file under it resolves neatly under the link's own
|
|
305
|
+
// target. Resolving nothing at all would fail honest projects instead, because
|
|
306
|
+
// a macOS temp or home path is itself reached through a link.
|
|
307
|
+
// The wire's `path_root` carries a trailing slash; `join` keeps it.
|
|
308
|
+
const suiteRoot = join(realpathSync(projectRoot), root).replace(/[\\/]+$/, '');
|
|
309
|
+
if (!realpathSync(onDisk).startsWith(`${suiteRoot}${sep}`)) {
|
|
310
|
+
throw new Error(`${path}: the ${suiteKind} ${fileLabel(support)} at "${filePath}" resolves outside the suite root — suite files must be real files under it.`);
|
|
311
|
+
}
|
|
312
|
+
const content = readFileSync(onDisk, 'utf8');
|
|
313
|
+
if (!content.trim()) {
|
|
314
|
+
throw new Error(`${path}: the ${suiteKind} ${fileLabel(support)} at "${filePath}" is empty.`);
|
|
315
|
+
}
|
|
316
|
+
return { path: filePath, content };
|
|
317
|
+
}
|
|
318
|
+
function fileLabel(support) {
|
|
319
|
+
return support ? 'support file' : 'suite_file';
|
|
104
320
|
}
|
|
105
321
|
function parseJson(raw, path) {
|
|
106
322
|
try {
|
|
@@ -110,6 +326,15 @@ function parseJson(raw, path) {
|
|
|
110
326
|
throw new Error(`${path} is not valid JSON (${err.message})`);
|
|
111
327
|
}
|
|
112
328
|
}
|
|
329
|
+
function stableJson(value) {
|
|
330
|
+
if (Array.isArray(value))
|
|
331
|
+
return `[${value.map(stableJson).join(',')}]`;
|
|
332
|
+
if (value && typeof value === 'object') {
|
|
333
|
+
const object = value;
|
|
334
|
+
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableJson(object[key])}`).join(',')}}`;
|
|
335
|
+
}
|
|
336
|
+
return JSON.stringify(value) ?? 'null';
|
|
337
|
+
}
|
|
113
338
|
// Turn a branch's assignment packet into its recipe name: structural uses the
|
|
114
339
|
// unit-guardrail recipe, behavioral the Gherkin one.
|
|
115
340
|
export function recipeNameFor(packet) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readHostSuiteOutputs, readSuiteBuildRequest } from "../files/suiteBuild.js";
|
|
1
|
+
import { readBehavioralReview, readHostSuiteOutputs, readSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
2
2
|
import { Wire } from "../wire.js";
|
|
3
3
|
// Read the task and the host's answers, verify each branch parses and carries a
|
|
4
4
|
// safe-path artifact envelope, then upload both peer branches in one batch
|
|
@@ -20,13 +20,36 @@ export async function putSuiteBuild(config, _args = [], deps) {
|
|
|
20
20
|
if (output.build_error) {
|
|
21
21
|
return { suite_kind: output.suite_kind, source_digest: sourceDigest, build_error: output.build_error };
|
|
22
22
|
}
|
|
23
|
+
let testMetadata = output.test_metadata;
|
|
24
|
+
if (output.suite_kind === 'behavioral') {
|
|
25
|
+
const review = readBehavioralReview(config.projectRoot, output);
|
|
26
|
+
const probe = review.known_defect_probe;
|
|
27
|
+
const qualityReview = review.bdd_quality_review;
|
|
28
|
+
if (!qualityReview || typeof qualityReview !== 'object') {
|
|
29
|
+
throw new Error('The separate behavioral review must contain a bdd_quality_review object.');
|
|
30
|
+
}
|
|
31
|
+
if (request.known_defect_context.status === 'supplied' && probe?.status === 'not_supplied') {
|
|
32
|
+
throw new Error('A known defect was supplied to suite-prepare, but the behavioral review marked it not_supplied.');
|
|
33
|
+
}
|
|
34
|
+
testMetadata = {
|
|
35
|
+
...output.test_metadata,
|
|
36
|
+
bdd_quality_review: {
|
|
37
|
+
...qualityReview,
|
|
38
|
+
candidate_digest: review.candidate_digest,
|
|
39
|
+
},
|
|
40
|
+
known_defect_probe: review.known_defect_probe,
|
|
41
|
+
known_defect_context: request.known_defect_context,
|
|
42
|
+
candidate_run: review.candidate_run,
|
|
43
|
+
...(review.fixed_candidate_run ? { fixed_candidate_run: review.fixed_candidate_run } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
23
46
|
return {
|
|
24
47
|
suite_kind: output.suite_kind,
|
|
25
48
|
source_digest: sourceDigest,
|
|
26
49
|
artifacts: {
|
|
27
50
|
suite_file: output.suite_file,
|
|
28
51
|
runner_manifest: output.runner_manifest,
|
|
29
|
-
test_metadata:
|
|
52
|
+
test_metadata: testMetadata,
|
|
30
53
|
},
|
|
31
54
|
};
|
|
32
55
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { materializeHelper } from "../files/guardrails.js";
|
|
4
|
-
import { recipeNameFor, writeSuiteBuildRequest } from "../files/suiteBuild.js";
|
|
4
|
+
import { recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
5
5
|
import { anyStackPrecheck } from "../runner/precheck.js";
|
|
6
6
|
import { ensureRunner } from "../runner/provision.js";
|
|
7
7
|
import { Wire } from "../wire.js";
|
|
@@ -13,7 +13,8 @@ import { Wire } from "../wire.js";
|
|
|
13
13
|
// unsupported project stops with one actionable message and writes nothing; a
|
|
14
14
|
// no-current-map error from the server surfaces (via WireError) with guidance to
|
|
15
15
|
// rebuild the map first.
|
|
16
|
-
export async function suitePrepare(config,
|
|
16
|
+
export async function suitePrepare(config, args = [], deps) {
|
|
17
|
+
const defectContext = knownDefectContext(args);
|
|
17
18
|
const wire = new Wire(config);
|
|
18
19
|
const actual = {
|
|
19
20
|
getRecipe: (name) => wire.getRecipe(name),
|
|
@@ -55,12 +56,15 @@ export async function suitePrepare(config, _args = [], deps) {
|
|
|
55
56
|
recipe: await actual.getRecipe(recipeNameFor(packet)),
|
|
56
57
|
assignment: packet.assignment,
|
|
57
58
|
})));
|
|
58
|
-
const request = writeSuiteBuildRequest(config.projectRoot, branches);
|
|
59
|
+
const request = writeSuiteBuildRequest(config.projectRoot, branches, defectContext);
|
|
59
60
|
const kinds = branches.map((branch) => branch.suite_kind).join(' and ');
|
|
61
|
+
const nextCommand = branches.some((branch) => branch.suite_kind === 'behavioral')
|
|
62
|
+
? '`unitbob suite-review-prepare` before upload'
|
|
63
|
+
: '`unitbob put-suite-build`';
|
|
60
64
|
actual.stdout.write(`Suite build request written to ${request.project_root}/.unitbob/suite-build/request.json\n`);
|
|
61
65
|
actual.stdout.write(`Next: build ${branches.length === 1 ? 'the' : 'both'} peer ${branches.length === 1 ? 'suite' : 'suites'} (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
|
|
62
|
-
`write your answer to ${request.output_path} as a branches array, run each locally
|
|
63
|
-
|
|
66
|
+
`write your answer to ${request.output_path} as a branches array, run each locally, repair broken harness steps while application failures remain red, ` +
|
|
67
|
+
`then run ${nextCommand}.\n`);
|
|
64
68
|
// A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
|
|
65
69
|
// vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
|
|
66
70
|
if (fixableNotices.length > 0) {
|
|
@@ -70,6 +74,32 @@ export async function suitePrepare(config, _args = [], deps) {
|
|
|
70
74
|
'\nFix the above, then re-run `unitbob suite-prepare` to build the behavioral peer.\n');
|
|
71
75
|
}
|
|
72
76
|
}
|
|
77
|
+
function knownDefectContext(args) {
|
|
78
|
+
const defect = option(args, '--known-defect=');
|
|
79
|
+
const fixedRevision = option(args, '--fixed-revision=');
|
|
80
|
+
const explicitlyAbsent = args.includes('--no-known-defect');
|
|
81
|
+
if ((defect && explicitlyAbsent) || (!defect && !explicitlyAbsent)) {
|
|
82
|
+
throw new Error('Choose exactly one of --known-defect or --no-known-defect (use --known-defect=<description>).');
|
|
83
|
+
}
|
|
84
|
+
if (fixedRevision && !defect)
|
|
85
|
+
throw new Error('--fixed-revision requires --known-defect.');
|
|
86
|
+
if (explicitlyAbsent)
|
|
87
|
+
return { status: 'not_supplied' };
|
|
88
|
+
if (!defect)
|
|
89
|
+
throw new Error('--known-defect requires a value.');
|
|
90
|
+
return {
|
|
91
|
+
status: 'supplied',
|
|
92
|
+
defect,
|
|
93
|
+
...(fixedRevision ? { fixed_revision: fixedRevision } : {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function option(args, prefix) {
|
|
97
|
+
const match = args.find((arg) => arg.startsWith(prefix));
|
|
98
|
+
const value = match?.slice(prefix.length).trim();
|
|
99
|
+
if (match && !value)
|
|
100
|
+
throw new Error(`${prefix.slice(0, -1)} requires a value.`);
|
|
101
|
+
return value || undefined;
|
|
102
|
+
}
|
|
73
103
|
function inferBddRunner(projectRoot) {
|
|
74
104
|
if (existsSync(join(projectRoot, 'package.json')))
|
|
75
105
|
return 'cucumber-js';
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { copyBehavioralRunnerEnvironment, filesLostOnMaterialize, materializeBehavioral, } from "../files/behavioral.js";
|
|
6
|
+
import { runBddSuite } from "../runner/bdd.js";
|
|
7
|
+
import { boundReport } from "../runner/boundReport.js";
|
|
8
|
+
import { branchRunner, readHostSuiteOutputs, readSuiteBuildRequest, reviewRequestPath, writeBehavioralReviewRequest, } from "../files/suiteBuild.js";
|
|
9
|
+
export async function suiteReviewPrepare(config, _args = [], deps) {
|
|
10
|
+
const actual = {
|
|
11
|
+
runCandidate: runCandidate,
|
|
12
|
+
stdout: process.stdout,
|
|
13
|
+
...deps,
|
|
14
|
+
};
|
|
15
|
+
const buildRequest = readSuiteBuildRequest(config.projectRoot);
|
|
16
|
+
const behavioral = readHostSuiteOutputs(buildRequest.output_path, buildRequest)
|
|
17
|
+
.find((branch) => branch.suite_kind === 'behavioral');
|
|
18
|
+
if (!behavioral)
|
|
19
|
+
throw new Error('The suite build has no behavioral candidate to review.');
|
|
20
|
+
if (behavioral.build_error) {
|
|
21
|
+
throw new Error(`The behavioral candidate could not be reviewed: ${behavioral.build_error.message}`);
|
|
22
|
+
}
|
|
23
|
+
// Say this before the run, not after: the run materializes the answer, and
|
|
24
|
+
// that is where a forgotten file turns into undefined steps — by then it is
|
|
25
|
+
// already gone.
|
|
26
|
+
const lost = filesLostOnMaterialize(config.projectRoot, behavioral.suite_file, branchRunner(behavioral));
|
|
27
|
+
if (lost.length > 0) {
|
|
28
|
+
actual.stdout.write(`Warning: these files sit with the suite but are not in its answer, and running it will delete them: ${lost.join(', ')}.\n`);
|
|
29
|
+
}
|
|
30
|
+
const candidateRun = await actual.runCandidate(config.projectRoot, behavioral);
|
|
31
|
+
const fixedRevision = buildRequest.known_defect_context.status === 'supplied'
|
|
32
|
+
? buildRequest.known_defect_context.fixed_revision
|
|
33
|
+
: undefined;
|
|
34
|
+
const fixedCandidateRun = fixedRevision
|
|
35
|
+
? await actual.runCandidate(config.projectRoot, behavioral, fixedRevision)
|
|
36
|
+
: undefined;
|
|
37
|
+
const request = writeBehavioralReviewRequest(config.projectRoot, behavioral, candidateRun, buildRequest.known_defect_context, fixedCandidateRun);
|
|
38
|
+
actual.stdout.write(`Behavioral review request written to ${reviewRequestPath(config.projectRoot)}\n`);
|
|
39
|
+
actual.stdout.write(`Next: have an independent reviewer inspect this exact candidate and write bdd_quality_review and known_defect_probe to ${request.output_path}, then run \`unitbob put-suite-build\`.\n`);
|
|
40
|
+
}
|
|
41
|
+
async function runCandidate(projectRoot, output, revision) {
|
|
42
|
+
if (revision)
|
|
43
|
+
return runCandidateAtRevision(projectRoot, output, revision);
|
|
44
|
+
return runCandidateInProject(projectRoot, output, gitRevision(projectRoot));
|
|
45
|
+
}
|
|
46
|
+
async function runCandidateInProject(projectRoot, output, revision) {
|
|
47
|
+
const runner = branchRunner(output);
|
|
48
|
+
const suiteFile = output.suite_file;
|
|
49
|
+
const mainPath = materializeBehavioral(projectRoot, suiteFile, runner).mainPath;
|
|
50
|
+
const result = await runBddSuite(projectRoot, runner, mainPath);
|
|
51
|
+
const report = boundReport(runner, result);
|
|
52
|
+
if (report === null)
|
|
53
|
+
throw new Error('The behavioral candidate produced no machine-readable runner report.');
|
|
54
|
+
return { revision, run_result: report };
|
|
55
|
+
}
|
|
56
|
+
async function runCandidateAtRevision(projectRoot, output, revision) {
|
|
57
|
+
const resolved = execFileSync('git', ['rev-parse', '--verify', revision], {
|
|
58
|
+
cwd: projectRoot,
|
|
59
|
+
encoding: 'utf8',
|
|
60
|
+
}).trim();
|
|
61
|
+
const worktree = mkdtempSync(join(tmpdir(), 'unitbob-fixed-review-'));
|
|
62
|
+
let added = false;
|
|
63
|
+
try {
|
|
64
|
+
execFileSync('git', ['worktree', 'add', '--detach', worktree, resolved], { cwd: projectRoot, stdio: 'pipe' });
|
|
65
|
+
added = true;
|
|
66
|
+
const runner = branchRunner(output);
|
|
67
|
+
materializeBehavioral(worktree, output.suite_file, runner);
|
|
68
|
+
copyBehavioralRunnerEnvironment(projectRoot, worktree, runner);
|
|
69
|
+
return await runCandidateInProject(worktree, output, revision);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
if (added) {
|
|
73
|
+
try {
|
|
74
|
+
execFileSync('git', ['worktree', 'remove', '--force', worktree], { cwd: projectRoot, stdio: 'pipe' });
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// The temporary directory cleanup below is still safe and bounded.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
rmSync(worktree, { recursive: true, force: true });
|
|
81
|
+
if (added) {
|
|
82
|
+
try {
|
|
83
|
+
// `remove` can fail while the directory still goes away just above,
|
|
84
|
+
// which leaves a dangling entry under .git/worktrees in the user's own
|
|
85
|
+
// checkout. Reviewing a suite must not litter the repository it reads.
|
|
86
|
+
execFileSync('git', ['worktree', 'prune'], { cwd: projectRoot, stdio: 'pipe' });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Housekeeping only — never fail a finished review run over it.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function gitRevision(projectRoot) {
|
|
95
|
+
try {
|
|
96
|
+
const options = {
|
|
97
|
+
cwd: projectRoot,
|
|
98
|
+
encoding: 'utf8',
|
|
99
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
100
|
+
};
|
|
101
|
+
const head = execFileSync('git', ['rev-parse', 'HEAD'], options).trim();
|
|
102
|
+
const dirty = execFileSync('git', ['status', '--porcelain'], options).trim();
|
|
103
|
+
return dirty ? `${head}-dirty` : head;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return 'working-tree';
|
|
107
|
+
}
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"unitbob": "dist/
|
|
7
|
+
"unitbob": "dist/bin.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist"
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"node": ">=18"
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
|
-
"build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/
|
|
16
|
+
"build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/bin.js",
|
|
17
17
|
"prepublishOnly": "npm run build",
|
|
18
18
|
"test": "node --test test/*.test.ts"
|
|
19
19
|
},
|