@sabaiway/agent-workflow-kit 5.4.0 → 5.6.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/CHANGELOG.md +130 -0
- package/README.md +1 -0
- package/SKILL.md +5 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +1 -1
- package/bridges/antigravity-cli-bridge/capability.json +1 -1
- package/bridges/codex-cli-bridge/SKILL.md +51 -4
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +616 -24
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +700 -1
- package/bridges/codex-cli-bridge/bin/codex-review.sh +1 -1
- package/bridges/codex-cli-bridge/capability.json +15 -10
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/dispatch.md +29 -0
- package/references/modes/gates.md +6 -3
- package/references/modes/procedures.md +2 -0
- package/references/modes/receipt-deadline.md +3 -3
- package/references/modes/recommendations.md +1 -1
- package/references/modes/velocity.md +1 -0
- package/tools/commands.mjs +7 -0
- package/tools/core-evidence.mjs +37 -3
- package/tools/detect-backends.mjs +5 -4
- package/tools/dispatch-record.mjs +10 -3
- package/tools/dispatch-store.mjs +392 -0
- package/tools/dispatch.mjs +1779 -0
- package/tools/doc-parity.mjs +10 -2
- package/tools/exec-producer.mjs +483 -0
- package/tools/exec-receipt.mjs +263 -0
- package/tools/flow-check-cores.mjs +253 -0
- package/tools/flow-check-git-lane.mjs +56 -0
- package/tools/flow-check-rungs.mjs +330 -0
- package/tools/flow-check.mjs +23 -611
- package/tools/flow-store.mjs +111 -462
- package/tools/gates-declaration.mjs +13 -1
- package/tools/gates-init.mjs +134 -22
- package/tools/procedures.mjs +64 -5
- package/tools/receipt-deadline.mjs +25 -3
- package/tools/recommendations.mjs +108 -7
- package/tools/release-scan.mjs +33 -0
- package/tools/source-size-check.mjs +320 -0
- package/tools/source-size-config.mjs +244 -0
- package/tools/source-size-core.mjs +53 -0
- package/tools/source-size-gate-cmd.mjs +55 -0
- package/tools/source-size-judge.mjs +114 -0
- package/tools/source-size-refusal.mjs +70 -0
- package/tools/source-size-report.mjs +254 -0
- package/tools/source-size-scope.mjs +145 -0
- package/tools/store-append.mjs +444 -0
- package/tools/velocity-profile.mjs +24 -3
|
@@ -55,7 +55,13 @@ import { surveyFamily, surveyGateHook, surveyAdrLayoutStrict } from './family-re
|
|
|
55
55
|
import { probeSandboxMasks, needsMasksApply } from './sandbox-masks.mjs';
|
|
56
56
|
import { shellQuoteArg } from './review-state.mjs';
|
|
57
57
|
import { isFinalCapableDeclaration } from './run-gates.mjs';
|
|
58
|
-
import { loadDeclaration, canonicalCheckerGates, coverageProducerPrecedes,
|
|
58
|
+
import { loadDeclaration, canonicalCheckerGates, coverageProducerPrecedes, isKitOwnedCheckerGate, GATES_REL } from './gates-declaration.mjs';
|
|
59
|
+
import { matchesCoverageProducer } from './coverage-producer.mjs';
|
|
60
|
+
// Read-only surfaces of the fill (buildOffer) and of the source-size practice (its pure core). The
|
|
61
|
+
// fill's own WRITER is never called from here — the advisor renders its consent-gated command, it
|
|
62
|
+
// does not run it.
|
|
63
|
+
import { buildOffer } from './gates-init.mjs';
|
|
64
|
+
import { INITIAL_ADOPTION_REASON, loadSourceSizeConfig, matchesSourceSizeGate } from './source-size-core.mjs';
|
|
59
65
|
// The declared-path resolution + segment containment this item's convergence lane shares with the
|
|
60
66
|
// autonomy render's allowWrite degrade — ONE leaf, so the two answers cannot drift.
|
|
61
67
|
import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './declared-paths.mjs';
|
|
@@ -106,6 +112,10 @@ export const SEVERITIES = Object.freeze({
|
|
|
106
112
|
'gates-declaration': SEVERITY_OPTIONAL,
|
|
107
113
|
'gates-inert': SEVERITY_ATTENTION,
|
|
108
114
|
'gates-inert.no-verification': SEVERITY_ATTENTION,
|
|
115
|
+
'source-size': SEVERITY_OPTIONAL,
|
|
116
|
+
// The declared-but-unminted arm reports a CONFIGURED declaration that is broken — a gate certain
|
|
117
|
+
// to refuse on every run — while the base arm stays an offer to enable something unconfigured.
|
|
118
|
+
'source-size.unminted': SEVERITY_ATTENTION,
|
|
109
119
|
'gate-hook': SEVERITY_OPTIONAL,
|
|
110
120
|
'commit-guard': SEVERITY_OPTIONAL,
|
|
111
121
|
'read-lane': SEVERITY_OPTIONAL,
|
|
@@ -166,6 +176,8 @@ export const WHATS = Object.freeze({
|
|
|
166
176
|
'gates-declaration': 'no declared gate matrix (docs/ai/gates.json absent or empty) — gates prompt one by one; the apply PREVIEWS its --apply line, writes nothing',
|
|
167
177
|
'gates-inert': 'the declared coverage checker ({id}) has no producer before it — it certifies nothing this run, or reads a stale lcov',
|
|
168
178
|
'gates-inert.no-verification': "all {n} declared gate(s) are the kit's own checkers — the matrix runs no project-verification command",
|
|
179
|
+
'source-size': 'no source-size gate — module size drifts unmeasured, and an over-cap file is invisible instead of recorded debt',
|
|
180
|
+
'source-size.unminted': 'the source-size gate is declared but its record ({state}) is not minted — the checker refuses, so this gate reds every run',
|
|
169
181
|
'gate-hook': '{n} declared gate(s) prompt per run — the gate-approval hook is not wired',
|
|
170
182
|
'commit-guard': 'the gate matrix is final-run-capable but no commit-guard arms the pre-commit hook — a commit needs no green receipt yet',
|
|
171
183
|
'read-lane': 'the gate hook is wired but the read-only compound lane is off — pipes/chains of seeded reads still prompt one by one',
|
|
@@ -223,6 +235,7 @@ export const BENEFITS = Object.freeze({
|
|
|
223
235
|
'review-recipe': 'recipe coverage — the review AND execution recipes you configured actually run instead of silently degrading',
|
|
224
236
|
'gates-declaration': 'velocity — your project’s gates run as ONE declared batch with a PASS/FAIL table',
|
|
225
237
|
'gates-inert': 'honest gates — the declared matrix verifies your project instead of reporting green over a check that ran nothing',
|
|
238
|
+
'source-size': 'maintainability — a module you can hold whole stays reviewable, and size drift becomes recorded, reasoned debt',
|
|
226
239
|
'gate-hook': 'velocity — your own declared gate commands auto-approve byte-exactly (opt-in PreToolUse hook)',
|
|
227
240
|
'commit-guard': 'integrity — commits require the ONE green --final receipt at the exact staged fingerprint (consented pre-commit arm)',
|
|
228
241
|
'read-lane': 'velocity — pipes/chains of your seeded read-only commands auto-approve instead of prompting (opt-in, conservatively classified)',
|
|
@@ -263,6 +276,10 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
|
|
|
263
276
|
// converges the moment any gate exists, so it can never observe this state. The advisor key names
|
|
264
277
|
// the state it reports (an inert declaration), the capability names what the user gains.
|
|
265
278
|
{ id: 'gates-verification', mode: 'gates', advisorKey: 'gates-inert' },
|
|
279
|
+
// The source-size practice is accepted AS a gate — it needs no mode of its own, and its capability
|
|
280
|
+
// is therefore declared where its declaration lives. It is the DISCOVERY lane for the practice on
|
|
281
|
+
// every deployment, new and existing alike: nothing else tells a project the practice exists.
|
|
282
|
+
{ id: 'source-size', mode: 'gates', advisorKey: 'source-size' },
|
|
266
283
|
{ id: 'gate-hook', mode: 'hook', advisorKey: 'gate-hook' },
|
|
267
284
|
{ id: 'read-lane', mode: 'hook', advisorKey: 'read-lane' },
|
|
268
285
|
{ id: 'commit-guard', mode: 'commit-guard', advisorKey: 'commit-guard' },
|
|
@@ -432,12 +449,23 @@ const probeGates = ({ root, deps, add, skip }) => {
|
|
|
432
449
|
//
|
|
433
450
|
// Cause A (a canonical coverage checker with no producer anywhere in the declaration) is checked
|
|
434
451
|
// FIRST and reported alone: its remedy — declaring the producer — also resolves cause B, because a
|
|
435
|
-
// producer gate is not a kit checker. Its apply
|
|
436
|
-
//
|
|
437
|
-
// the
|
|
452
|
+
// producer gate is not a kit checker. Its apply follows what the FILL can actually do, which the
|
|
453
|
+
// D-8 placement rule changed: when the checker is the declaration's LAST gate and the project's own
|
|
454
|
+
// scripts yield an offerable producer, the fill now PLACES that producer before the checker, so the
|
|
455
|
+
// remedy is the ordinary consent-gated preview. HAND-APPLY remains exactly where no offerable
|
|
456
|
+
// producer exists, or where the checker is not last — there the edit really is the maintainer's,
|
|
457
|
+
// because the fill never reorders entries it did not write.
|
|
438
458
|
//
|
|
439
459
|
// An ABSENT or EMPTY declaration belongs to the gates-declaration item; a malformed one throws out
|
|
440
460
|
// of the validated reader and becomes this probe's stated skip, never a guess.
|
|
461
|
+
// ONE rule for every fill preview this item renders: name only the entries the fill would ACCEPT.
|
|
462
|
+
// An id the declaration already carries is refused as a collision, so an unrestricted preview hands
|
|
463
|
+
// the reader a lane that cannot converge — and both arms of this item render over a declaration that
|
|
464
|
+
// already carries at least one gate the offer also proposes, so both need the rule. With nothing
|
|
465
|
+
// selectable the bare preview is still the honest render: its own notes say why.
|
|
466
|
+
const fillPreviewFor = (root, ids) =>
|
|
467
|
+
`node ${q(toolPath('gates-init.mjs'))} --cwd ${q(root)}${ids.map((id) => ` --only ${id}`).join('')}`;
|
|
468
|
+
|
|
441
469
|
export const probeGatesInert = ({ root, deps, add, skip }) => {
|
|
442
470
|
try {
|
|
443
471
|
const declaration = loadDeclaration(root, deps);
|
|
@@ -451,18 +479,54 @@ export const probeGatesInert = ({ root, deps, add, skip }) => {
|
|
|
451
479
|
// only --final refuses that shape — a plain run reports every gate PASS.
|
|
452
480
|
if (coverageProducerPrecedes(gates, gates.indexOf(checkers[0]))) return; // the pair is live
|
|
453
481
|
const id = truncatedTo(oneLineOf(checkers[0].id), templateBudget(WHATS['gates-inert']));
|
|
482
|
+
// The fill can only help when it would land the producer in the right place: the checker must
|
|
483
|
+
// be LAST (that is the one position the placement rule inserts before) and the offer must
|
|
484
|
+
// actually carry a producer the fill would ACCEPT. An offered id that is already declared is
|
|
485
|
+
// refused as a collision, so counting it here would render a preview that cannot fix what the
|
|
486
|
+
// item just reported.
|
|
487
|
+
const declaredIds = new Set(gates.map((gate) => gate.id));
|
|
488
|
+
const checkerIsLast = checkers[0] === gates[gates.length - 1];
|
|
489
|
+
// The PRODUCER entry is kept, not just its existence: the rendered preview must name it with
|
|
490
|
+
// --only. A whole-offer apply here collides by construction — the declaration already carries
|
|
491
|
+
// the checker, and the offer carries it too — so an unrestricted preview would hand the reader
|
|
492
|
+
// a lane that refuses instead of the one entry that resolves what the item just reported.
|
|
493
|
+
const producer = buildOffer(root, deps).entries
|
|
494
|
+
.find((entry) => !declaredIds.has(entry.id) && matchesCoverageProducer(entry.cmd));
|
|
495
|
+
// The two ways the fill cannot help are DIFFERENT situations and need different sentences: a
|
|
496
|
+
// checker that is not last blocks a placement even when a producer is offerable, and saying no
|
|
497
|
+
// producer exists there would be plainly false to a reader looking at their own scripts.
|
|
498
|
+
const blocked = checkerIsLast
|
|
499
|
+
? `no offerable producer exists here, and the fill never reorders entries it did not write`
|
|
500
|
+
: `${id} is not the LAST declared gate, so there is no trailing position to place a producer before, and the fill never reorders entries it did not write`;
|
|
454
501
|
add(
|
|
455
502
|
'gates-inert',
|
|
456
503
|
fillTemplate(WHATS['gates-inert'], { id }),
|
|
457
|
-
|
|
504
|
+
checkerIsLast && producer
|
|
505
|
+
? fillPreviewFor(root, [producer.id])
|
|
506
|
+
: `HAND-APPLY: declare or MOVE a suite gate carrying the coverage reporters BEFORE ${id} in ${GATES_REL} (references/modes/gates.md names the exact form), or drop ${id} — ${blocked}`,
|
|
458
507
|
);
|
|
459
508
|
return;
|
|
460
509
|
}
|
|
461
|
-
|
|
510
|
+
// The source-size checker is one of the kit's OWN checkers, and it is deliberately NOT
|
|
511
|
+
// review-dependent (it needs no receipt), so the review-dependent predicate alone cannot see it.
|
|
512
|
+
// Left out, a matrix of nothing but that gate reads as carrying project verification — and a
|
|
513
|
+
// project that just adopted the practice and declared nothing else would be told it is optimal.
|
|
514
|
+
// The source-size checker is one of the kit's OWN checkers, and it is deliberately NOT
|
|
515
|
+
// review-dependent (it needs no receipt), so the review-dependent predicate alone cannot see it.
|
|
516
|
+
// Left out, a matrix of nothing but that gate reads as carrying project verification — and a
|
|
517
|
+
// project that just adopted the practice and declared nothing else would be told it is optimal.
|
|
518
|
+
if (gates.every((gate) => isKitOwnedCheckerGate(gate, root))) {
|
|
519
|
+
const declaredIds = new Set(gates.map((gate) => gate.id));
|
|
520
|
+
// Only PROJECT-verification entries: a non-colliding entry is not enough, it has to be one
|
|
521
|
+
// that RESOLVES the item, and declaring one more kit checker converges nothing — the item
|
|
522
|
+
// would simply fire again on the next run.
|
|
523
|
+
const selectable = buildOffer(root, deps).entries
|
|
524
|
+
.filter((entry) => !declaredIds.has(entry.id) && !isKitOwnedCheckerGate(entry, root))
|
|
525
|
+
.map((entry) => entry.id);
|
|
462
526
|
add(
|
|
463
527
|
'gates-inert',
|
|
464
528
|
fillTemplate(WHATS['gates-inert.no-verification'], { n: gates.length }),
|
|
465
|
-
|
|
529
|
+
fillPreviewFor(root, selectable),
|
|
466
530
|
'gates-inert.no-verification',
|
|
467
531
|
);
|
|
468
532
|
}
|
|
@@ -471,6 +535,42 @@ export const probeGatesInert = ({ root, deps, add, skip }) => {
|
|
|
471
535
|
}
|
|
472
536
|
};
|
|
473
537
|
|
|
538
|
+
// The SOURCE-SIZE offer (baseline-practices Plan 1). This is the practice's ONE discovery lane: the
|
|
539
|
+
// checker ships with the kit and refuses until a project declares its own scope, so without this
|
|
540
|
+
// item a deployment would never learn the practice exists — the same OPT-IN-SHIPS-INVISIBLE failure
|
|
541
|
+
// the capability registry was built for. New and existing deployments meet it identically, because
|
|
542
|
+
// the advisor section is mandatory at every upgrade.
|
|
543
|
+
//
|
|
544
|
+
// The probe asks ONE question — does the declaration carry the canonical source-size gate? — through
|
|
545
|
+
// the practice's own matcher, so an id squatter (a gate called `source-size` running something else)
|
|
546
|
+
// never reads as adopted. It deliberately does NOT key on the config's state: a project with no
|
|
547
|
+
// config is exactly the project that needs to hear about the practice, and the apply's own refusal
|
|
548
|
+
// is what teaches the one manual step (authoring the scope). A missing declaration is not a skip —
|
|
549
|
+
// there is no source-size gate in it either.
|
|
550
|
+
const probeSourceSize = ({ root, deps, add, skip }) => {
|
|
551
|
+
try {
|
|
552
|
+
const declaration = loadDeclaration(root, deps);
|
|
553
|
+
const gates = declaration.outcome === 'loaded' ? declaration.gates : [];
|
|
554
|
+
const applyLine = `node ${q(toolPath('source-size-check.mjs'))} --adopt --reason "${INITIAL_ADOPTION_REASON}" --cwd ${q(root)}`;
|
|
555
|
+
if (gates.some((gate) => matchesSourceSizeGate(gate.cmd, root))) {
|
|
556
|
+
// A DECLARED gate is not the same fact as a working one: the checker refuses on every config
|
|
557
|
+
// state but MINTED, so a gate declared over an absent or half-written record reds the matrix
|
|
558
|
+
// on every run. Reading the declaration alone would report that deployment as adopted and say
|
|
559
|
+
// nothing about the one thing that is wrong with it.
|
|
560
|
+
const { state } = loadSourceSizeConfig(root, deps);
|
|
561
|
+
if (state === 'minted') return; // adopted and armed — converged
|
|
562
|
+
add('source-size', fillTemplate(WHATS['source-size.unminted'], { state }), applyLine, 'source-size.unminted');
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
// The reason string is PINNED, not composed: it is copied unchanged into every entry the first
|
|
566
|
+
// mint records, and a first mint records the whole tree — so "this is what the tree already
|
|
567
|
+
// carried when the practice arrived" is the one sentence that is true of all of them.
|
|
568
|
+
add('source-size', fillTemplate(WHATS['source-size'], {}), applyLine);
|
|
569
|
+
} catch (err) {
|
|
570
|
+
skip('source-size', err);
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
|
|
474
574
|
// The D10 consumer surface: once the declaration is FINAL-capable (the canonical core checks
|
|
475
575
|
// present, the checker LAST — the run-gates helper is the one home of that rule), offer the
|
|
476
576
|
// consented commit-guard install — for a MANAGED guardless pre-commit hook AND for an absent one
|
|
@@ -1054,6 +1154,7 @@ const PROBES = Object.freeze([
|
|
|
1054
1154
|
probeReviewRecipe,
|
|
1055
1155
|
probeGates,
|
|
1056
1156
|
probeGatesInert,
|
|
1157
|
+
probeSourceSize,
|
|
1057
1158
|
probeCommitGuard,
|
|
1058
1159
|
probeReadLane,
|
|
1059
1160
|
probeStateBlockHook,
|
package/tools/release-scan.mjs
CHANGED
|
@@ -35,11 +35,44 @@ const ATTRIBUTION = [
|
|
|
35
35
|
{ re: /reviewed by (claude|codex|chatgpt|gpt|gemini|copilot|cursor|the (ai|model|agent))/i, label: 'AI review attribution' },
|
|
36
36
|
{ re: /authored[- ]by[: ][^\n]{0,40}\b(claude|chatgpt|gemini|copilot)\b/i, label: 'AI authorship attribution' },
|
|
37
37
|
];
|
|
38
|
+
// A line whose FIRST non-space token opens a comment (or a markdown heading) — the surface where a
|
|
39
|
+
// backend name beside a disposition is a credit rather than a data value.
|
|
40
|
+
//
|
|
41
|
+
// STATED RESIDUAL of the two anchored rules below, named rather than implied: they see only lines
|
|
42
|
+
// that OPEN with a comment marker, so a credit inside a string literal, in ordinary markdown prose
|
|
43
|
+
// outside a heading, or in a trailing inline comment is invisible to them. Closing that class needs
|
|
44
|
+
// a code/comment/string lexer this scanner deliberately does not have (the version-pin rung below
|
|
45
|
+
// records what hand-rolling one cost). The UNANCHORED pair narrows the gap for the one construction
|
|
46
|
+
// that has actually shipped past this gate; nothing here proves no attribution exists — it is a
|
|
47
|
+
// high-signal guard, not a proof.
|
|
48
|
+
const COMMENT_LINE = String.raw`^\s*(?://|/\*|\*|#)`;
|
|
49
|
+
const DISPOSITIONS = 'CONFIRM|REFUTE|REVISE|SHIP';
|
|
50
|
+
// Spelled per letter rather than flagged: the four rules below must be case-insensitive on the NAME
|
|
51
|
+
// and case-SENSITIVE on the disposition, and a regex flag cannot apply to half a pattern. All four
|
|
52
|
+
// share this constant, so an ALL-CAPS credit cannot slip past one of them.
|
|
53
|
+
const BACKEND_ANY_CASE = '[Aa][Gg][Yy]|[Cc][Oo][Dd][Ee][Xx]';
|
|
54
|
+
|
|
38
55
|
const REVIEWER_IDENTITY = [
|
|
39
56
|
// backend-then-round: a bridge name, a separator, then r<N> (with optional +/round suffixes).
|
|
40
57
|
{ re: /\b(?:agy|codex)(?:\s+|-)r\d+(?:(?:\+|\/)r?\d+)*(?:-[a-z0-9]+)*\b/i, label: 'reviewer-round identity' },
|
|
41
58
|
// round-then-backend (reverse order): r<N>, a separator, then a bridge name — the release-review gap.
|
|
42
59
|
{ re: /\br\d+(?:\s+|-)(?:agy|codex)\b/i, label: 'reviewer-round identity' },
|
|
60
|
+
// backend beside a DISPOSITION, in a COMMENT: the form a fold note takes when it credits who
|
|
61
|
+
// decided instead of stating what was decided. Two narrowings make it usable. The line must be a
|
|
62
|
+
// comment, because a receipt FIXTURE legitimately pairs a backend field with a verdict value and
|
|
63
|
+
// that is data, not attribution. And the disposition half is case-SENSITIVE, because prose says
|
|
64
|
+
// "ship" and "revise" constantly while a recorded disposition is written in caps.
|
|
65
|
+
{ re: new RegExp(`${COMMENT_LINE}[^\\n]*\\b(?:${BACKEND_ANY_CASE})\\b[^\\n]{0,24}\\b(?:${DISPOSITIONS})\\b`), label: 'reviewer-round identity' },
|
|
66
|
+
{ re: new RegExp(`${COMMENT_LINE}[^\\n]*\\b(?:${DISPOSITIONS})\\b[^\\n]{0,24}\\b(?:${BACKEND_ANY_CASE})\\b`), label: 'reviewer-round identity' },
|
|
67
|
+
// The exact PARENTHESISED credit — an open paren, a bridge name, a comma, a disposition, and the
|
|
68
|
+
// reverse order — matched ANYWHERE on the line, so it also lands inside a string literal, a
|
|
69
|
+
// markdown sentence and a trailing inline comment, none of which the comment anchor above can
|
|
70
|
+
// see. It stays clear of data because a fixture QUOTES its values, and a quote sits exactly where
|
|
71
|
+
// this rule requires the bare disposition word. Both orders END on a word boundary, so a longer
|
|
72
|
+
// word sharing a disposition's prefix is not a credit — the boundary is what a commit gate needs,
|
|
73
|
+
// and requiring the CLOSING paren instead would drop the hyphenated form this rule exists for.
|
|
74
|
+
{ re: new RegExp(`\\((?:${BACKEND_ANY_CASE}),\\s*(?:${DISPOSITIONS})\\b`), label: 'reviewer-round identity' },
|
|
75
|
+
{ re: new RegExp(`\\((?:${DISPOSITIONS}),\\s*(?:${BACKEND_ANY_CASE})\\b`), label: 'reviewer-round identity' },
|
|
43
76
|
];
|
|
44
77
|
|
|
45
78
|
const allowlistCovers = (matched, allowlist) =>
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// source-size-check.mjs — the source-size practice's CLI and its writer half. The pure read core
|
|
3
|
+
// (config, scope, counting, the canonical gate-cmd matcher) lives in source-size-core.mjs, the
|
|
4
|
+
// verdict in source-size-judge.mjs and its wording in source-size-report.mjs; all three are imported
|
|
5
|
+
// here. NOTHING in the read graph imports THIS module, so the advisor surfaces can ask about the
|
|
6
|
+
// practice without ever reaching a writer (D-18).
|
|
7
|
+
//
|
|
8
|
+
// What --check judges: every in-scope file against the config's `defaults`, and every RECORDED size
|
|
9
|
+
// against the ratchet instead — a record may not grow, may not sit above what the tree measures, and
|
|
10
|
+
// may not outlive its file. Each declared root carries the same ratchet over its summed lines, so
|
|
11
|
+
// splitting one big file into six buys no headroom.
|
|
12
|
+
//
|
|
13
|
+
// What --write-baseline does: regenerates the machine keys from the tree, and ONLY the machine keys.
|
|
14
|
+
// The authored half is copied through with its VALUES and their ORDER preserved exactly; the file
|
|
15
|
+
// itself is canonically serialized, so its formatting becomes the writer's (that is what makes a
|
|
16
|
+
// regeneration of an unchanged tree reproduce the same bytes). A regeneration that RAISES any value takes
|
|
17
|
+
// --reason "<text>" — the checker cannot invent the human's reason — and that string lands verbatim
|
|
18
|
+
// in the entry it raises. A pure tighten needs none: shrinking is progress. The printed old→new
|
|
19
|
+
// delta is the durable record where docs/ai is git-hidden; it is what the commit message carries.
|
|
20
|
+
//
|
|
21
|
+
// What --adopt does: mints the record and declares the gate — the whole adoption as ONE consented
|
|
22
|
+
// line, because the alternative is a two-step ceremony whose halves can be left half-done. It
|
|
23
|
+
// composes the two writers it already has (this module's mint, the fill's consented apply restricted
|
|
24
|
+
// to this one id) and owns no write of its own.
|
|
25
|
+
//
|
|
26
|
+
// Exit codes: 0 green / 1 violation or refusal / 2 usage, config or enumeration error.
|
|
27
|
+
// Dependency-free, Node >= 22. No side effects on import.
|
|
28
|
+
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { realpathSync } from 'node:fs';
|
|
31
|
+
import { resolve } from 'node:path';
|
|
32
|
+
import { assertDocsAiDeployment, writeDocsAiFileAtomic } from './atomic-write.mjs';
|
|
33
|
+
import {
|
|
34
|
+
AUTHORED_KEYS,
|
|
35
|
+
SOURCE_SIZE_CONFIG_REL,
|
|
36
|
+
SOURCE_SIZE_GATE_ID,
|
|
37
|
+
loadSourceSizeConfig,
|
|
38
|
+
matchesSourceSizeGate,
|
|
39
|
+
reasonDefect,
|
|
40
|
+
scopeFail,
|
|
41
|
+
} from './source-size-core.mjs';
|
|
42
|
+
import { changesFor, isRaise, judgeTree, ownEntry } from './source-size-judge.mjs';
|
|
43
|
+
import { GATES_REL, loadDeclaration } from './gates-declaration.mjs';
|
|
44
|
+
import { applyFill } from './gates-init.mjs';
|
|
45
|
+
import {
|
|
46
|
+
absentRefusalLines,
|
|
47
|
+
adoptAbsentRefusalLines,
|
|
48
|
+
checkReportLines,
|
|
49
|
+
gateAlreadyDeclaredLines,
|
|
50
|
+
gateDeclaredLines,
|
|
51
|
+
gateRefusedLines,
|
|
52
|
+
reasonRequiredLines,
|
|
53
|
+
recordNoLongerHoldsLines,
|
|
54
|
+
recordRecognizedLines,
|
|
55
|
+
unmintedRefusalLines,
|
|
56
|
+
writtenLines,
|
|
57
|
+
} from './source-size-report.mjs';
|
|
58
|
+
|
|
59
|
+
const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: 2 });
|
|
60
|
+
const RECORD_NOUN = 'the source-size record';
|
|
61
|
+
|
|
62
|
+
export const runCheck = ({ cwd, deps = {} }) => {
|
|
63
|
+
const { state, config, missingMachineKeys } = loadSourceSizeConfig(cwd, deps);
|
|
64
|
+
if (state === 'absent') return { code: 1, lines: absentRefusalLines(cwd) };
|
|
65
|
+
if (state !== 'minted') return { code: 1, lines: unmintedRefusalLines(cwd, { state, missing: missingMachineKeys }) };
|
|
66
|
+
const verdict = judgeTree(cwd, config, deps);
|
|
67
|
+
return { code: verdict.findings.length === 0 ? 0 : 1, lines: checkReportLines({ cwd, config, verdict }) };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// ── the writer ────────────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
// planRecord computes what the regeneration WOULD write, before any reason is applied — so a raise
|
|
73
|
+
// can be refused without ever building an entry that carries no reason. It reads the JUDGE's
|
|
74
|
+
// projection, never its own: the checker's verdict and the file the writer produces are then two
|
|
75
|
+
// renderings of one computation, and neither can promise what the other would not do.
|
|
76
|
+
const planRecord = ({ config, verdict }) => {
|
|
77
|
+
const oldBaseline = config.baseline ?? {};
|
|
78
|
+
const oldAggregate = config.aggregate ?? {};
|
|
79
|
+
const files = verdict.scope.files.map((rel) => {
|
|
80
|
+
const next = verdict.projected.get(rel);
|
|
81
|
+
const recorded = ownEntry(oldBaseline, rel);
|
|
82
|
+
return { rel, next, recorded, changes: changesFor(rel, next, recorded) };
|
|
83
|
+
});
|
|
84
|
+
const roots = config.roots.map((root) => ({
|
|
85
|
+
root,
|
|
86
|
+
lines: verdict.rootLines.get(root) ?? 0,
|
|
87
|
+
recorded: ownEntry(oldAggregate, root),
|
|
88
|
+
}));
|
|
89
|
+
const deltas = [
|
|
90
|
+
...files.flatMap(({ changes }) => changes),
|
|
91
|
+
// A record whose file left scope is REMOVED, never kept: that is what makes a split or a rename
|
|
92
|
+
// visible in the delta instead of silently surviving as headroom.
|
|
93
|
+
...Object.entries(oldBaseline)
|
|
94
|
+
.filter(([rel]) => !verdict.measured.has(rel))
|
|
95
|
+
.flatMap(([rel, recorded]) => changesFor(rel, {}, recorded)),
|
|
96
|
+
...roots
|
|
97
|
+
.filter(({ lines, recorded }) => (recorded ? recorded.lines : null) !== lines)
|
|
98
|
+
.map(({ root, lines, recorded }) => ({ target: root, dimension: 'aggregate lines', from: recorded ? recorded.lines : null, to: lines })),
|
|
99
|
+
...Object.entries(oldAggregate)
|
|
100
|
+
.filter(([root]) => !config.roots.includes(root))
|
|
101
|
+
.map(([root, recorded]) => ({ target: root, dimension: 'aggregate lines', from: recorded.lines, to: null })),
|
|
102
|
+
];
|
|
103
|
+
return { files, roots, deltas, raises: deltas.filter(isRaise) };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// The reason of an entry the regeneration did NOT raise is the one already recorded — a tighten
|
|
107
|
+
// rewrites a number, never the human sentence that justified it.
|
|
108
|
+
const materialize = ({ files, roots }, reason) => ({
|
|
109
|
+
baseline: Object.fromEntries(
|
|
110
|
+
files
|
|
111
|
+
.filter(({ next }) => Object.keys(next).length > 0)
|
|
112
|
+
.map(({ rel, next, recorded, changes }) => [rel, {
|
|
113
|
+
...next,
|
|
114
|
+
reason: changes.some(isRaise) ? reason : recorded.reason,
|
|
115
|
+
}]),
|
|
116
|
+
),
|
|
117
|
+
aggregate: Object.fromEntries(
|
|
118
|
+
roots.map(({ root, lines, recorded }) => [root, {
|
|
119
|
+
lines,
|
|
120
|
+
reason: !recorded || lines > recorded.lines ? reason : recorded.reason,
|
|
121
|
+
}]),
|
|
122
|
+
),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// The file is CANONICALLY serialized: the authored VALUES and the ORDER the human wrote them in are
|
|
126
|
+
// preserved exactly, their formatting is not — this file is machine-maintained and the writer owns
|
|
127
|
+
// half of it, so one deterministic rendering is what makes "regenerate an unchanged tree and get the
|
|
128
|
+
// same bytes" true at all.
|
|
129
|
+
const serialize = (parsed, machineKeys) => {
|
|
130
|
+
const authored = Object.fromEntries(Object.keys(parsed).filter((key) => AUTHORED_KEYS.includes(key)).map((key) => [key, parsed[key]]));
|
|
131
|
+
return `${JSON.stringify({ ...authored, ...machineKeys }, null, 2)}\n`;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const runWriteBaseline = ({ cwd, reason, deps = {} }) => {
|
|
135
|
+
// Checked BEFORE the config is read, so a project that was never deployed hears about the
|
|
136
|
+
// deployment rather than about a file it has no place to put.
|
|
137
|
+
assertDocsAiDeployment(cwd, deps, { stop: scopeFail, rel: SOURCE_SIZE_CONFIG_REL, noun: RECORD_NOUN });
|
|
138
|
+
const { state, config, parsed, text } = loadSourceSizeConfig(cwd, deps);
|
|
139
|
+
if (state === 'absent') return { code: 1, lines: absentRefusalLines(cwd) };
|
|
140
|
+
const verdict = judgeTree(cwd, config, deps);
|
|
141
|
+
const plan = planRecord({ config, verdict });
|
|
142
|
+
if (plan.raises.length > 0 && reason === undefined) return { code: 1, lines: reasonRequiredLines(cwd, plan.deltas) };
|
|
143
|
+
const body = serialize(parsed, materialize(plan, reason));
|
|
144
|
+
// Whether anything was written is decided by the BYTES, not by the delta count: completing a
|
|
145
|
+
// hand-edited half record changes the file while raising nothing at all.
|
|
146
|
+
const changed = body !== text;
|
|
147
|
+
if (changed) writeDocsAiFileAtomic(cwd, SOURCE_SIZE_CONFIG_REL, body, deps, { stop: scopeFail, noun: RECORD_NOUN });
|
|
148
|
+
return { code: 0, lines: writtenLines({ cwd, deltas: plan.deltas, reason: plan.raises.length > 0 ? reason : undefined, changed }) };
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// ── the adoption verb (D-16) ──────────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
// Declaring the gate is delegated to the FILL's own consented apply, restricted to this one id: the
|
|
154
|
+
// fill owns every rule about what a written declaration may look like (placement, id collisions, the
|
|
155
|
+
// coverage invariant, the atomic write), and a second writer here would be a second set of those
|
|
156
|
+
// rules — the one that drifts. `--adopt` is therefore a composition, never a re-implementation.
|
|
157
|
+
//
|
|
158
|
+
// The already-declared arm is checked FIRST and through the practice's own matcher: an earlier
|
|
159
|
+
// partial run leaves a minted record and a declared gate, and re-running must converge rather than
|
|
160
|
+
// collide. A gate that merely CARRIES the id without being this checker does not count — it reaches
|
|
161
|
+
// the fill and collides there, loudly, which is the honest answer to a squatter.
|
|
162
|
+
// The READ is inside the try with the write, deliberately: by the time this runs the record is
|
|
163
|
+
// already minted, so ANY failure here — a malformed declaration the reader throws on, just as much
|
|
164
|
+
// as a collision the fill refuses — must still report both halves. Letting the read escape would
|
|
165
|
+
// surface a bare error carrying neither the mint that succeeded nor the exit contract this tool
|
|
166
|
+
// documents.
|
|
167
|
+
const declareGate = (cwd, deps) => {
|
|
168
|
+
try {
|
|
169
|
+
applyFill({ cwd, onlyIds: [SOURCE_SIZE_GATE_ID] }, deps);
|
|
170
|
+
return { code: 0, lines: gateDeclaredLines(GATES_REL, SOURCE_SIZE_GATE_ID) };
|
|
171
|
+
} catch (err) {
|
|
172
|
+
return { code: 1, lines: gateRefusedLines(GATES_REL, err?.message ?? String(err)) };
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Is the canonical gate ALREADY there? Asked through the practice's own matcher, so a gate that
|
|
177
|
+
// merely carries the id — running something else entirely — never reads as adopted; it reaches the
|
|
178
|
+
// fill and collides there, loudly, which is the honest answer to a squatter.
|
|
179
|
+
//
|
|
180
|
+
// An unreadable declaration is NOT a verdict here. This read is a shortcut, and aborting on it would
|
|
181
|
+
// report an outcome before the record was settled; the fill re-reads the same file and refuses
|
|
182
|
+
// authoritatively AFTER, so the partial report names both halves truthfully.
|
|
183
|
+
const gateIsDeclared = (cwd, deps) => {
|
|
184
|
+
try {
|
|
185
|
+
const declaration = loadDeclaration(cwd, deps);
|
|
186
|
+
return (declaration.outcome === 'loaded' ? declaration.gates : []).some((gate) => matchesSourceSizeGate(gate.cmd, cwd));
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// A MINTED record is RECOGNIZED, never regenerated. Adoption carries a PINNED reason — the advisor
|
|
193
|
+
// renders it as a fixed string, and its item keeps firing while the gate is undeclared — so a
|
|
194
|
+
// re-run after a partial adoption would let that one sentence raise whatever the tree grew in the
|
|
195
|
+
// meantime. That is exactly the laundering the reason requirement exists to prevent, so the verb
|
|
196
|
+
// asks the checker instead: a record that no longer holds is a ratchet question with its own
|
|
197
|
+
// reasoned lane, and it must be answered BEFORE a gate is declared over it — declaring one that is
|
|
198
|
+
// certain to red the matrix is what the offer rules refuse everywhere else.
|
|
199
|
+
const recognizeRecord = ({ cwd, deps }) => {
|
|
200
|
+
const verdict = runCheck({ cwd, deps });
|
|
201
|
+
if (verdict.code !== 0) return { code: verdict.code, lines: [...verdict.lines, ...recordNoLongerHoldsLines(GATES_REL)] };
|
|
202
|
+
return { code: 0, lines: recordRecognizedLines(cwd) };
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// --adopt = settle the record, then declare the gate. The order is not a preference: the fill offers
|
|
206
|
+
// the gate ONLY over a minted config (declaring it earlier would declare a gate that refuses), so
|
|
207
|
+
// the record is what makes the declaration offerable at all.
|
|
208
|
+
export const runAdopt = ({ cwd, reason, deps = {} }) => {
|
|
209
|
+
const { state } = loadSourceSizeConfig(cwd, deps);
|
|
210
|
+
if (state === 'absent') return { code: 1, lines: adoptAbsentRefusalLines(cwd) };
|
|
211
|
+
// ADOPTED is asked FIRST, and it is a question about the GATE alone. Idempotence cannot be made
|
|
212
|
+
// conditional on the record still holding: a declared gate reports its own staleness on every run,
|
|
213
|
+
// with the reasoned lane, and stopping here would tell a reader the gate was not declared while it
|
|
214
|
+
// plainly is — which is exactly what re-running the advisor's one-liner on a drifted tree does.
|
|
215
|
+
// Once the gate is there and the record is minted, nothing is left to adopt.
|
|
216
|
+
const declared = gateIsDeclared(cwd, deps);
|
|
217
|
+
if (declared && state === 'minted') return { code: 0, lines: gateAlreadyDeclaredLines(GATES_REL) };
|
|
218
|
+
// Either half carries its OWN self-servable refusals (a raise with no reason, a project with no
|
|
219
|
+
// docs/ai, a record the tree outgrew). They are returned unchanged: re-wording them here would be
|
|
220
|
+
// the second practice this module exists to avoid, and each already names the step that clears it.
|
|
221
|
+
const record = state === 'minted' ? recognizeRecord({ cwd, deps }) : runWriteBaseline({ cwd, reason, deps });
|
|
222
|
+
if (record.code !== 0) return record;
|
|
223
|
+
if (declared) return { code: 0, lines: [...record.lines, ...gateAlreadyDeclaredLines(GATES_REL)] };
|
|
224
|
+
const gate = declareGate(cwd, deps);
|
|
225
|
+
return { code: gate.code, lines: [...record.lines, ...gate.lines] };
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
const MODES = Object.freeze(['--check', '--write-baseline', '--adopt']);
|
|
231
|
+
|
|
232
|
+
const HELP = `source-size-check — the declared source-size practice (agent-workflow family).
|
|
233
|
+
|
|
234
|
+
Usage:
|
|
235
|
+
node source-size-check.mjs --check [--cwd <project-root>]
|
|
236
|
+
node source-size-check.mjs --write-baseline [--reason "<text>"] [--cwd <project-root>]
|
|
237
|
+
node source-size-check.mjs --adopt [--reason "<text>"] [--cwd <project-root>]
|
|
238
|
+
|
|
239
|
+
Judges every in-scope file against ${SOURCE_SIZE_CONFIG_REL}: git-tracked files under a declared
|
|
240
|
+
root carrying a declared extension, minus the excluded path-segment prefixes. Scope is DECLARED,
|
|
241
|
+
never guessed — with the config absent the check REFUSES and prints the exact file to author.
|
|
242
|
+
Symlinks and submodule gitlinks are skipped by kind; an unmerged index, a non-UTF-8 in-scope path,
|
|
243
|
+
an unverifiable in-scope file and an empty declared scope are refusals, never silent greens.
|
|
244
|
+
|
|
245
|
+
Counting: lines, and the longest line in BYTES. A terminator never counts (the CR of a CRLF
|
|
246
|
+
included); a last line with no final newline still counts; an empty file is 0 lines.
|
|
247
|
+
|
|
248
|
+
A file carrying a recorded baseline entry is recorded DEBT: it is judged against the record, which
|
|
249
|
+
may not grow, may not sit above the measured size, and may not outlive its file. Each declared root
|
|
250
|
+
carries the same ratchet over its summed lines.
|
|
251
|
+
|
|
252
|
+
--write-baseline regenerates the machine keys (baseline, aggregate) from the tree. The authored keys
|
|
253
|
+
keep their values and their order; the file is canonically serialized, so its formatting is the
|
|
254
|
+
writer's. A regeneration that RAISES any recorded value needs --reason; the string is recorded in
|
|
255
|
+
the entry it raised. A pure tighten needs none.
|
|
256
|
+
|
|
257
|
+
--adopt is the ONE-line adoption: it mints the record and declares the source-size gate (and NOTHING
|
|
258
|
+
else) in docs/ai/gates.json. It is idempotent on an already-adopted project. With the config absent
|
|
259
|
+
it refuses with the exact file to author — that authoring is the practice's single manual step, and
|
|
260
|
+
the refusal says so. A refused declaration exits nonzero and reports both halves: what was minted and
|
|
261
|
+
what was not declared.
|
|
262
|
+
|
|
263
|
+
Exit codes: 0 green; 1 violation or refusal; 2 usage, config or enumeration error.`;
|
|
264
|
+
|
|
265
|
+
const takeOption = (argv, flag) => {
|
|
266
|
+
const at = argv.indexOf(flag);
|
|
267
|
+
if (at === -1) return { rest: argv, value: undefined };
|
|
268
|
+
if (argv[at + 1] === undefined) throw usageFail(`${flag} needs a value`);
|
|
269
|
+
return { rest: [...argv.slice(0, at), ...argv.slice(at + 2)], value: argv[at + 1] };
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
export const main = (argv, ctx = {}) => {
|
|
273
|
+
try {
|
|
274
|
+
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
275
|
+
const cwdOption = takeOption(argv, '--cwd');
|
|
276
|
+
const reasonOption = takeOption(cwdOption.rest, '--reason');
|
|
277
|
+
// Resolved BEFORE the run and before anything is rendered: every path this run names is then
|
|
278
|
+
// meaningful from any directory, not only from the one that invoked it.
|
|
279
|
+
const cwd = resolve(ctx.cwd ?? process.cwd(), cwdOption.value ?? '.');
|
|
280
|
+
// Counted over the ARGUMENTS, not over the mode list: filtering the list collapses repeats, so
|
|
281
|
+
// `--adopt --adopt` read as exactly one mode and a WRITE ran under an argument list this very
|
|
282
|
+
// guard had just called invalid.
|
|
283
|
+
const modes = reasonOption.rest.filter((arg) => MODES.includes(arg));
|
|
284
|
+
if (modes.length === 0) throw usageFail(`nothing to do — pass one of ${MODES.join(', ')} (see --help)`);
|
|
285
|
+
if (modes.length > 1) throw usageFail(`pass exactly ONE mode, got: ${modes.join(', ')}`);
|
|
286
|
+
const unknown = reasonOption.rest.filter((arg) => !MODES.includes(arg));
|
|
287
|
+
if (unknown.length > 0) throw usageFail(`unknown argument: ${unknown[0]}`);
|
|
288
|
+
if (reasonOption.value !== undefined) {
|
|
289
|
+
if (modes[0] === '--check') throw usageFail('--reason belongs to --write-baseline and --adopt — a check records nothing');
|
|
290
|
+
const defect = reasonDefect(reasonOption.value);
|
|
291
|
+
if (defect) throw usageFail(defect);
|
|
292
|
+
}
|
|
293
|
+
const deps = ctx.deps ?? {};
|
|
294
|
+
const run = { '--check': runCheck, '--write-baseline': runWriteBaseline, '--adopt': runAdopt }[modes[0]];
|
|
295
|
+
const { code, lines } = run({ cwd, reason: reasonOption.value, deps });
|
|
296
|
+
return { code, stdout: lines.join('\n'), stderr: '' };
|
|
297
|
+
} catch (err) {
|
|
298
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: `source-size-check: ${err.message}` };
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// Compared by REAL path, not lexically: ESM resolves a symlinked entry point to its target, so a
|
|
303
|
+
// lexical comparison is false whenever the tool is invoked through a link — and a gate whose cmd
|
|
304
|
+
// names a link would then exit 0 having run nothing, which reads as PASS.
|
|
305
|
+
// Exported as a test seam (the coverage-check keyFor idiom): the unresolvable arm cannot be reached
|
|
306
|
+
// through the CLI, where an existing entry point is a precondition of getting this far.
|
|
307
|
+
export const sameFile = (a, b) => {
|
|
308
|
+
try {
|
|
309
|
+
return realpathSync(a) === realpathSync(b);
|
|
310
|
+
} catch {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
const isDirectRun = Boolean(process.argv[1]) && sameFile(fileURLToPath(import.meta.url), process.argv[1]);
|
|
315
|
+
if (isDirectRun) {
|
|
316
|
+
const result = main(process.argv.slice(2));
|
|
317
|
+
if (result.stdout) process.stdout.write(result.stdout.endsWith('\n') ? result.stdout : `${result.stdout}\n`);
|
|
318
|
+
if (result.stderr) process.stderr.write(result.stderr.endsWith('\n') ? result.stderr : `${result.stderr}\n`);
|
|
319
|
+
process.exitCode = result.code;
|
|
320
|
+
}
|