cohorte 1.3.3 → 1.3.4
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 +90 -0
- package/README.md +4 -4
- package/bin/cli.js +22 -4
- package/core/agents/implementer.template.md +10 -5
- package/core/commands/cycle.md +15 -8
- package/core/commands/doctor.md +3 -1
- package/core/hooks/gate.py +21 -6
- package/core/templates/agent-handoff.md +7 -2
- package/core/templates/review-feedback.md +7 -4
- package/core/templates/spec.template.md +5 -2
- package/core/templates/steps/init-pipeline/04-write-render.md +2 -1
- package/core/workflows/audit.js +20 -3
- package/core/workflows/cycle.js +146 -40
- package/core/workflows/refactor.js +16 -5
- package/core/workflows/review.js +59 -7
- package/dashboard/README.md +22 -5
- package/dashboard/dist/assets/index-AFQnlfjO.css +1 -0
- package/dashboard/dist/assets/{index-BxgA_mz1.js → index-DLBzciIC.js} +12 -11
- package/dashboard/dist/index.html +2 -2
- package/dashboard/server/doctor.js +60 -19
- package/dashboard/server/fleet.js +19 -5
- package/dashboard/server/index.js +79 -7
- package/dashboard/server/metrics.js +15 -4
- package/dashboard/server/versions.js +28 -6
- package/dashboard/server/yaml.js +4 -1
- package/install.ps1 +4 -0
- package/install.sh +19 -1
- package/package.json +5 -2
- package/profile/SCHEMA.md +28 -9
- package/scripts/kanban-move.sh +34 -20
- package/scripts/new-feature.sh.template +3 -1
- package/scripts/preflight.sh +16 -3
- package/scripts/remove-feature.sh.template +2 -1
- package/scripts/telemetry-send.sh +15 -1
- package/scripts/test-dashboard.mjs +356 -0
- package/scripts/test-gate.mjs +273 -0
- package/scripts/test-workflows.mjs +443 -0
- package/scripts/validate-core.mjs +49 -0
- package/dashboard/dist/assets/index-Cj0SpgEY.css +0 -1
package/profile/SCHEMA.md
CHANGED
|
@@ -162,7 +162,11 @@ Coarse first, specialize on evidence: start with one `frontend` / `backend` surf
|
|
|
162
162
|
surface that's proven slow and cleanly separable. The evidence lives in
|
|
163
163
|
the **main checkout's** `.claude/pipeline-metrics.jsonl` (gitignored) — one JSONL line per phase batch
|
|
164
164
|
(`ts`/`feature`/`phase`/`seconds`/`surfaces:{key: result}`), appended by `/build`, `/review`, `/fix`
|
|
165
|
-
and `/smoke
|
|
165
|
+
and `/smoke`, plus a `phase: "cycle"` line from the cycle workflow.
|
|
166
|
+
**`surfaces` keys are surface keys, nothing else** — run-level facts go in their own top-level
|
|
167
|
+
fields (the cycle line carries `rounds` and `smoke` there). Anything put inside `surfaces` is read
|
|
168
|
+
as a surface: the dashboard renders it as a row in the per-surface table and scores a non-`ok`
|
|
169
|
+
value as that surface failing. Always the main checkout, never the feature worktree (which dies at teardown while
|
|
166
170
|
metrics must accumulate across features) — resolve from anywhere with
|
|
167
171
|
`$(dirname "$(git rev-parse --git-common-dir)")/.claude/pipeline-metrics.jsonl`. Read it before
|
|
168
172
|
proposing a split: split the surface that actually dominates wall-clock, not the one that feels big.
|
|
@@ -268,7 +272,9 @@ this exact procedure so a surface is always defined the same way. To add surface
|
|
|
268
272
|
this feature also changes. The link is self-contained — no stored project id. Build with the code UI
|
|
269
273
|
kit (the `design_system_project`'s materialization: `@/components/ui/*` + tokens); read a primitive
|
|
270
274
|
via `get_file` only if it's missing/stale in code. Mobile-first."_
|
|
271
|
-
- `<SURFACE_TDD_STEP1>` —
|
|
275
|
+
- `<SURFACE_TDD_STEP1>` — a **lead-in paragraph** above the TDD list (not a numbered item; it
|
|
276
|
+
renders as nothing for a non-design surface, which is why the list must not start at it):
|
|
277
|
+
_"**Pull the feature design first** (skip if your dispatch's design slot
|
|
272
278
|
says `none`): `DesignSync get_file(<projectId>, <file>)` for each link in the slot and translate
|
|
273
279
|
each into the code design system (`@/components/ui/*`, `cn()` + CVA), mobile-first — never ad-hoc
|
|
274
280
|
CSS. Then:"_
|
|
@@ -313,9 +319,9 @@ itself changes in ways `/build` §1.5 can't auto-grow (e.g. package manager or c
|
|
|
313
319
|
|
|
314
320
|
## Workflows — deterministic multi-agent runs (opt-in)
|
|
315
321
|
|
|
316
|
-
|
|
322
|
+
Four phases have a **workflow variant** — a deterministic orchestration script the Claude Code
|
|
317
323
|
Workflow runtime executes instead of the lead reasoning out the fan-out turn by turn:
|
|
318
|
-
`<core>/workflows/review.js`, `audit.js`, `refactor.js` (installed to `.claude/workflows/` bundled or
|
|
324
|
+
`<core>/workflows/review.js`, `audit.js`, `refactor.js`, `cycle.js` (installed to `.claude/workflows/` bundled or
|
|
319
325
|
`~/.claude/workflows/` global). The conversational commands (`/review`, `/audit`, `/refactor`)
|
|
320
326
|
**remain the default path and the fallback** — a workflow runs only when the human explicitly asks
|
|
321
327
|
for it ("run the review workflow", or via the `/cycle <id>` launcher command, which resolves
|
|
@@ -325,7 +331,7 @@ enabled.
|
|
|
325
331
|
`/brainstorm`, `/spec`) and the dispatch-only ones (`/build`, `/ship`) have **no** workflow variant on
|
|
326
332
|
purpose: they're interviews or already a single parallel dispatch — a script adds nothing.
|
|
327
333
|
|
|
328
|
-
Shared design, all
|
|
334
|
+
Shared design, all four scripts:
|
|
329
335
|
|
|
330
336
|
- **Phase 0 is always `profile-reader`** — workflow scripts have no filesystem or shell access, so a
|
|
331
337
|
dedicated agent (`core/agents/profile-reader.md`, haiku, read-only) reads `PIPELINE.md` and returns
|
|
@@ -336,6 +342,12 @@ Shared design, all three scripts:
|
|
|
336
342
|
- **Only the verdict comes back.** Bulk (diffs, reports, backlogs) is staged to the same disk
|
|
337
343
|
buffers the commands use (`specs/reports/`, `specs/refactor-backlog.md`); the workflow's return is
|
|
338
344
|
counts + verdict + paths.
|
|
345
|
+
- **A dead agent is never a clean result.** `agent()` resolves to `null` when a subagent dies, and a
|
|
346
|
+
dead *reviewer* returns zero findings — byte-identical to a surface that is genuinely clean. Any
|
|
347
|
+
script that derives a verdict from "how many findings came back" must first subtract the agents
|
|
348
|
+
that never answered: `review.js` and `cycle.js` name them in `unreviewedSurfaces`, refuse to score
|
|
349
|
+
`SHIP`, and (in the cycle) never tick the DoD or stamp the freshness gate. `scripts/test-workflows.mjs`
|
|
350
|
+
pins this — it is the one invariant the structural checks in `validate-core.mjs` cannot see.
|
|
339
351
|
- **`review.js`** — preflight gate (aborts red, zero agents), one `git diff --stat` staged per
|
|
340
352
|
touched surface, one reviewer per surface in parallel, then an **adversarial cross-check** phase
|
|
341
353
|
that tries to refute each CRITICAL/security finding before it can trigger a fix loop.
|
|
@@ -345,8 +357,8 @@ Shared design, all three scripts:
|
|
|
345
357
|
conversational `/refactor` is cheaper there): `shared` first and alone, then the other domains'
|
|
346
358
|
implementers in parallel, each verified per-domain.
|
|
347
359
|
- **`cycle.js`** — the **full dev cycle** on a frozen spec: contract → parallel build → rounds of
|
|
348
|
-
[preflight →
|
|
349
|
-
**zero open findings + a PASS smoke** (`maxRounds`, default 5, and the token budget are runaway
|
|
360
|
+
[preflight → review(+cross-check) (∥ smoke if opted in) → fix on the surfaces with findings], looping until
|
|
361
|
+
**zero open findings (+ a PASS smoke when opted in)** (`maxRounds`, default 5, and the token budget are runaway
|
|
350
362
|
protection, not targets). Since a workflow can't ask anything mid-run, the decisions move to the
|
|
351
363
|
edges: a **readiness gate** aborts up front if the spec isn't frozen (other gaps ride along as
|
|
352
364
|
deferred questions), and everything genuinely human comes back at the END in the result's
|
|
@@ -355,7 +367,8 @@ Shared design, all three scripts:
|
|
|
355
367
|
contract file (exactly what conversational `/fix` §1 does — implementers still never touch it),
|
|
356
368
|
the consuming surfaces re-dispatch, and the loop continues; the re-authorings are reported in the
|
|
357
369
|
result's `contractChanges` for the human to review in the diff. A clean exit ticks the DoD and
|
|
358
|
-
stamps the freshness gate
|
|
370
|
+
stamps the freshness gate (when smoke was skipped — the default — the runtime-flows DoD box stays
|
|
371
|
+
unticked and `/ship` flags it); a stopped run appends its open
|
|
359
372
|
findings to the spec's `## Remediation` so a rerun of the cycle — or a conversational `/fix` —
|
|
360
373
|
continues seamlessly. `/ship` itself stays outside on purpose — outward-facing and irreversible,
|
|
361
374
|
it keeps its human confirmation.
|
|
@@ -416,7 +429,7 @@ card created in the target column if missing.
|
|
|
416
429
|
| `/spec` opens (draft) | `spec` |
|
|
417
430
|
| `/spec` freezes (`status: frozen`) | `ready` |
|
|
418
431
|
| `/build` | `building` |
|
|
419
|
-
| `/
|
|
432
|
+
| `/review` (owns the move — `/smoke` never moves the card) | `review` |
|
|
420
433
|
| `/fix` | `fix` |
|
|
421
434
|
| `/ship` starts | `ship` |
|
|
422
435
|
| PR opened (`status: shipped`) | `shipped` (+ `PR #<num>` on the card) |
|
|
@@ -457,6 +470,12 @@ see where features stall, so every stage of `idea → PR` reports and nothing el
|
|
|
457
470
|
| `fix` | after the batch metrics line | wall-clock | `<fixed>/<found>` |
|
|
458
471
|
| `ship` | the release agent succeeded | `0` | `pr` / `compare` |
|
|
459
472
|
|
|
473
|
+
> Workflow-variant runs (`review.js`, `cycle.js`) report `seconds: 0` for their phases — only the
|
|
474
|
+
> conversational commands measure wall-clock. `cycle.js` also reports `fix` as `rounds:<n>` (the
|
|
475
|
+
> number of fix dispatches it made) rather than `<fixed>/<found>`: it never counts items the way a
|
|
476
|
+
> conversational `/fix` does. `results` is a free-text summary field, so both forms are valid — but
|
|
477
|
+
> read the `fix` column knowing which path produced it.
|
|
478
|
+
|
|
460
479
|
`seconds: 0` marks a phase whose duration is human thinking time, not pipeline wall-clock — the
|
|
461
480
|
funnel signal there is the event, not how long it took. `/doctor`, `/audit`, `/refactor`,
|
|
462
481
|
`/align-ds`, `/init-pipeline` and `/update-pipeline` **never** ping: they sit outside the funnel, and
|
package/scripts/kanban-move.sh
CHANGED
|
@@ -28,28 +28,44 @@ done
|
|
|
28
28
|
|
|
29
29
|
tmp="${board}.kanban-move.$$"
|
|
30
30
|
ID="$id" COL="$col" PR="$pr" TITLE="${title:-$id}" awk '
|
|
31
|
+
# Emit one line, collapsing runs of blank lines to a single one. Without this
|
|
32
|
+
# every move netted +1 blank line in the target column (the card is inserted
|
|
33
|
+
# with its own separator, right after the blank that already preceded the next
|
|
34
|
+
# heading) — ten moves of one card padded a board with fifteen blank lines.
|
|
35
|
+
function emit(s) {
|
|
36
|
+
if (s == "") { if (lastblank) return; lastblank = 1 } else lastblank = 0
|
|
37
|
+
print s
|
|
38
|
+
}
|
|
39
|
+
function put_card( k) {
|
|
40
|
+
emit(card)
|
|
41
|
+
for (k = 1; k <= sn; k++) emit(snote[k])
|
|
42
|
+
}
|
|
31
43
|
BEGIN {
|
|
32
44
|
id = ENVIRON["ID"]; col = tolower(ENVIRON["COL"])
|
|
33
45
|
pr = ENVIRON["PR"]; title = ENVIRON["TITLE"]
|
|
34
|
-
n = 0; card = "";
|
|
46
|
+
n = 0; card = ""; found = 0; colseen = 0; sn = 0; lastblank = 0
|
|
35
47
|
}
|
|
36
48
|
{ lines[++n] = $0 }
|
|
37
49
|
END {
|
|
38
50
|
# pass 1: extract the FIRST card block tagged #id; mark every block for removal
|
|
39
51
|
for (i = 1; i <= n; i++) {
|
|
40
52
|
l = lines[i]
|
|
41
|
-
if (l
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
if (l !~ /^- \[.\] /) continue
|
|
54
|
+
p = index(l, "#" id)
|
|
55
|
+
if (p == 0) continue
|
|
56
|
+
# word-boundary check: char after the tag must not extend the id
|
|
57
|
+
rest = substr(l, p + length(id) + 1, 1)
|
|
58
|
+
if (rest != "" && rest ~ /[A-Za-z0-9_-]/) continue
|
|
59
|
+
# Capture the sub-notes of the FIRST match only. Testing `card == lines[i]`
|
|
60
|
+
# instead also captured a later duplicate whose text happened to match.
|
|
61
|
+
isfirst = 0
|
|
62
|
+
if (!found) { found = 1; card = l; isfirst = 1 }
|
|
63
|
+
del[i] = 1
|
|
64
|
+
for (j = i + 1; j <= n && lines[j] ~ /^[ \t]/; j++) {
|
|
65
|
+
del[j] = 1
|
|
66
|
+
if (isfirst) snote[++sn] = lines[j]
|
|
52
67
|
}
|
|
68
|
+
i = j - 1
|
|
53
69
|
}
|
|
54
70
|
if (!found) card = "- [ ] " title " #" id
|
|
55
71
|
if (pr != "" && index(card, "PR #") == 0) card = card " — PR #" pr
|
|
@@ -63,22 +79,20 @@ ID="$id" COL="$col" PR="$pr" TITLE="${title:-$id}" awk '
|
|
|
63
79
|
atheading = (l ~ /^## /)
|
|
64
80
|
atsettings = (l ~ /^%% kanban:settings/)
|
|
65
81
|
if (intarget && (atheading || atsettings)) {
|
|
66
|
-
# back up over
|
|
67
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
print ""
|
|
82
|
+
# can not back up over already-printed lines; place the card just before
|
|
83
|
+
# this boundary instead
|
|
84
|
+
put_card()
|
|
85
|
+
emit("")
|
|
71
86
|
intarget = 0; placed = 1
|
|
72
87
|
}
|
|
73
88
|
if (atheading) {
|
|
74
89
|
h = tolower(l); sub(/^## +/, "", h); gsub(/ +$/, "", h)
|
|
75
90
|
if (h == col) { intarget = 1; colseen = 1 }
|
|
76
91
|
}
|
|
77
|
-
|
|
92
|
+
emit(l)
|
|
78
93
|
}
|
|
79
94
|
if (intarget && !placed) {
|
|
80
|
-
|
|
81
|
-
if (sub_notes != "") printf "%s", sub_notes
|
|
95
|
+
put_card()
|
|
82
96
|
placed = 1
|
|
83
97
|
}
|
|
84
98
|
if (!colseen) exit 3
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
#
|
|
3
3
|
# new-feature.sh — spin up an ISOLATED git worktree for one feature so features
|
|
4
4
|
# can be built fully in parallel. Rendered from a template by /init-pipeline;
|
|
5
|
-
# the
|
|
5
|
+
# the double-underscore tokens below are substituted from PIPELINE.md §isolation.
|
|
6
|
+
# (This comment deliberately avoids spelling the token pattern: /doctor flags any
|
|
7
|
+
# leftover double-underscore-caps token in the RENDERED script as unrendered.)
|
|
6
8
|
#
|
|
7
9
|
# Usage: scripts/new-feature.sh <feature_id>
|
|
8
10
|
#
|
package/scripts/preflight.sh
CHANGED
|
@@ -40,9 +40,22 @@ for cmd in "$@"; do
|
|
|
40
40
|
done
|
|
41
41
|
|
|
42
42
|
# Stamp for the gate.py phase gate: epoch + HEAD sha of the checkout we verified.
|
|
43
|
-
|
|
43
|
+
# The stamp MUST land where gate.py reads it: <main checkout>/.claude. CLAUDE_PROJECT_DIR
|
|
44
|
+
# is only exported to hooks — in an agent's Bash call it is unset, and a bare `.` would
|
|
45
|
+
# drop the stamp in the feature worktree where the gate never looks. git-common-dir
|
|
46
|
+
# resolves to the MAIN checkout's .git from any worktree.
|
|
47
|
+
proj="${CLAUDE_PROJECT_DIR:-$(dirname "$(git rev-parse --git-common-dir 2>/dev/null || echo ./.git)")}"
|
|
44
48
|
sha=$(git rev-parse HEAD 2>/dev/null || echo none)
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
# Stamp BOTH the main checkout and the cwd: gate.py reads CLAUDE_PROJECT_DIR,
|
|
50
|
+
# which is the worktree when the session was opened there and the main checkout
|
|
51
|
+
# when it wasn't — the two disagree, and either layout is supported.
|
|
52
|
+
now=$(date +%s)
|
|
53
|
+
last=""
|
|
54
|
+
for d in "$proj" "$(pwd)"; do
|
|
55
|
+
[ "$d" = "$last" ] && continue # same dir twice in the main checkout
|
|
56
|
+
last="$d"
|
|
57
|
+
mkdir -p "$d/.claude" 2>/dev/null || true
|
|
58
|
+
printf '%s %s\n' "$now" "$sha" > "$d/.claude/preflight.ok" 2>/dev/null || true
|
|
59
|
+
done
|
|
47
60
|
|
|
48
61
|
echo "PREFLIGHT PASS ($n checks green) — full log: $report"
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
#
|
|
3
3
|
# remove-feature.sh — tear down a feature's isolated worktree. Rendered from a
|
|
4
|
-
# template by /init-pipeline (
|
|
4
|
+
# template by /init-pipeline (double-underscore tokens from PIPELINE.md §isolation —
|
|
5
|
+
# spelled out here so /doctor's unrendered-token check never trips on this comment).
|
|
5
6
|
#
|
|
6
7
|
# Usage: scripts/remove-feature.sh <feature_id> [--drop-db]
|
|
7
8
|
#
|
|
@@ -43,12 +43,26 @@ case "$phase" in
|
|
|
43
43
|
*) exit 0 ;;
|
|
44
44
|
esac
|
|
45
45
|
|
|
46
|
+
# The payload is hand-built JSON: a non-numeric seconds or a quote in results
|
|
47
|
+
# would silently produce an invalid document the collector drops.
|
|
48
|
+
case "$seconds" in ''|*[!0-9]*) seconds=0 ;; esac
|
|
49
|
+
results=$(printf '%s' "$results" | tr -d '"\\\n\r' | cut -c1-80)
|
|
50
|
+
|
|
46
51
|
if command -v shasum >/dev/null 2>&1; then
|
|
47
52
|
fhash=$(printf '%s' "$feature" | shasum -a 256 | cut -c1-12)
|
|
48
53
|
else
|
|
49
54
|
fhash=$(printf '%s' "$feature" | sha256sum | cut -c1-12)
|
|
50
55
|
fi
|
|
51
|
-
|
|
56
|
+
# The core that shipped THIS script is the one whose version we report. Bundled
|
|
57
|
+
# installs live at <project>/.claude/pipeline/scripts/, where the global VERSION is
|
|
58
|
+
# either absent or a DIFFERENT core — reading only the global path made every
|
|
59
|
+
# bundled repo report an empty core_version.
|
|
60
|
+
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd 2>/dev/null) || here=""
|
|
61
|
+
ver=""
|
|
62
|
+
for v in "$here/../VERSION" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/pipeline/VERSION"; do
|
|
63
|
+
[ -n "$ver" ] && break
|
|
64
|
+
ver=$(head -1 "$v" 2>/dev/null | tr -d '"\\')
|
|
65
|
+
done
|
|
52
66
|
os=$(uname -s 2>/dev/null || echo unknown)
|
|
53
67
|
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
54
68
|
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for the dashboard's server modules (dashboard/server/*.js).
|
|
3
|
+
//
|
|
4
|
+
// These are shipped runtime code with real logic and zero coverage until now:
|
|
5
|
+
// a hand-rolled YAML parser that every /doctor check is derived from, a metrics
|
|
6
|
+
// aggregator, the JS port of /doctor, an Obsidian board parser, the fleet
|
|
7
|
+
// registry, and an HTTP layer whose guards are the dashboard's only defence
|
|
8
|
+
// against a web page driving the local agent.
|
|
9
|
+
//
|
|
10
|
+
// node scripts/test-dashboard.mjs
|
|
11
|
+
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import net from "node:net";
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
21
|
+
const { parse, parseProfileBlock } = require(join(root, "dashboard/server/yaml.js"));
|
|
22
|
+
const { metrics } = require(join(root, "dashboard/server/metrics.js"));
|
|
23
|
+
const { state, scanSpecs } = require(join(root, "dashboard/server/doctor.js"));
|
|
24
|
+
const { kanban } = require(join(root, "dashboard/server/kanban.js"));
|
|
25
|
+
const fleet = require(join(root, "dashboard/server/fleet.js"));
|
|
26
|
+
|
|
27
|
+
let failures = 0;
|
|
28
|
+
const check = (name, cond, detail = "") => {
|
|
29
|
+
if (cond) console.log(` ✓ ${name}`);
|
|
30
|
+
else { failures++; console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`); }
|
|
31
|
+
};
|
|
32
|
+
const eq = (name, got, want) =>
|
|
33
|
+
check(name, JSON.stringify(got) === JSON.stringify(want), `got ${JSON.stringify(got)}`);
|
|
34
|
+
|
|
35
|
+
const tmps = [];
|
|
36
|
+
const scratch = () => { const d = mkdtempSync(join(tmpdir(), "dash-")); tmps.push(d); return d; };
|
|
37
|
+
const write = (p, s) => { mkdirSync(join(p, ".."), { recursive: true }); writeFileSync(p, s); };
|
|
38
|
+
|
|
39
|
+
// ── yaml.js ──────────────────────────────────────────────────────────────────
|
|
40
|
+
console.log("yaml.js — the profile parser");
|
|
41
|
+
{
|
|
42
|
+
eq("scalars: bool / int / null / quoted",
|
|
43
|
+
parse("a: true\nb: 12\nc: null\nd: \"x: y\"\ne: ~"),
|
|
44
|
+
{ a: true, b: 12, c: null, d: "x: y", e: null });
|
|
45
|
+
eq("flow array", parse("t: [Read, Write, mcp__serena]").t, ["Read", "Write", "mcp__serena"]);
|
|
46
|
+
eq("flow map", parse("p: { api: 3333, web: 5173 }").p, { api: 3333, web: 5173 });
|
|
47
|
+
eq("nested map", parse("a:\n b:\n c: 1").a.b, { c: 1 });
|
|
48
|
+
eq("block sequence of scalars", parse("d:\n - one\n - two").d, ["one", "two"]);
|
|
49
|
+
eq("block sequence of maps",
|
|
50
|
+
parse("s:\n - key: a\n path: x\n - key: b\n path: y").s,
|
|
51
|
+
[{ key: "a", path: "x" }, { key: "b", path: "y" }]);
|
|
52
|
+
eq("comment after a value is stripped", parse("a: 1 # note").a, 1);
|
|
53
|
+
eq("a # inside quotes is NOT a comment", parse('a: "x # y"').a, "x # y");
|
|
54
|
+
eq("a # not preceded by whitespace is literal", parse("a: c#d").a, "c#d");
|
|
55
|
+
eq("empty value ⇒ null", parse("a:").a, null);
|
|
56
|
+
eq("a colon in the value survives", parse('m: "cd x && node ace migration:run"').m,
|
|
57
|
+
"cd x && node ace migration:run");
|
|
58
|
+
eq("empty flow array", parse("a: []").a, []);
|
|
59
|
+
check("no fenced block ⇒ null", parseProfileBlock("# just prose") === null);
|
|
60
|
+
|
|
61
|
+
// The real thing: the shipped template must round-trip.
|
|
62
|
+
const tpl = readFileSync(join(root, "profile/PIPELINE.template.md"), "utf8");
|
|
63
|
+
const p = parseProfileBlock(tpl);
|
|
64
|
+
check("the shipped PIPELINE.template.md parses", !!p);
|
|
65
|
+
eq("…surfaces are a list of 2", (p.surfaces || []).length, 2);
|
|
66
|
+
eq("…surface tools survive as an array", p.surfaces[0].tools.length, 7);
|
|
67
|
+
eq("…uses_design is a real boolean", p.surfaces[1].uses_design, true);
|
|
68
|
+
eq("…build_cmd stays an empty string, not null", p.surfaces[0].build_cmd, "");
|
|
69
|
+
eq("…gate.deny is a list of 4", p.gate.deny.length, 4);
|
|
70
|
+
eq("…gate.preflight.max_age_minutes is a number", p.gate.preflight.max_age_minutes, 30);
|
|
71
|
+
eq("…port_base flow map", p.isolation.port_base, { api: 3333, web: 5173 });
|
|
72
|
+
eq("…a chained migrate command survives", p.commands.migrate, "cd apps/api && node ace migration:run");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── metrics.js ───────────────────────────────────────────────────────────────
|
|
76
|
+
console.log("metrics.js — the funnel aggregate");
|
|
77
|
+
{
|
|
78
|
+
const d = scratch();
|
|
79
|
+
const lines = [
|
|
80
|
+
JSON.stringify({ ts: "2026-01-01T00:00:00Z", feature: "f1", phase: "build", seconds: 100, surfaces: { backend: "ok", frontend: "error" } }),
|
|
81
|
+
JSON.stringify({ ts: "2026-01-01T01:00:00Z", feature: "f1", phase: "review", seconds: 50, surfaces: { backend: "REVISE:2" } }),
|
|
82
|
+
JSON.stringify({ ts: "2026-01-01T02:00:00Z", feature: "f1", phase: "fix", seconds: 20, surfaces: { backend: "ok" } }),
|
|
83
|
+
JSON.stringify({ ts: "2026-01-01T03:00:00Z", feature: "f1", phase: "cycle", seconds: 0, rounds: 3, smoke: "SKIPPED", surfaces: { backend: "SHIP:0" } }),
|
|
84
|
+
// legacy: one line PER surface, folded into one batch, wall-clock = max
|
|
85
|
+
JSON.stringify({ ts: "2026-01-02T00:00:00Z", feature: "f2", phase: "build", surface: "backend", seconds: 10, result: "ok" }),
|
|
86
|
+
JSON.stringify({ ts: "2026-01-02T00:00:00Z", feature: "f2", phase: "build", surface: "frontend", seconds: 40, result: "ok" }),
|
|
87
|
+
"not json at all",
|
|
88
|
+
JSON.stringify({ ts: "x", feature: "f3" }), // no phase ⇒ skipped
|
|
89
|
+
JSON.stringify({ ts: "x", feature: "f3", phase: "build" }), // neither surfaces nor surface ⇒ skipped
|
|
90
|
+
];
|
|
91
|
+
mkdirSync(join(d, ".claude"), { recursive: true });
|
|
92
|
+
writeFileSync(join(d, ".claude", "pipeline-metrics.jsonl"), lines.join("\n") + "\n");
|
|
93
|
+
const m = metrics({ projectRoot: d });
|
|
94
|
+
|
|
95
|
+
eq("malformed + incomplete lines are skipped", m.batches, 5);
|
|
96
|
+
const f1 = m.features.find(f => f.feature === "f1");
|
|
97
|
+
const f2 = m.features.find(f => f.feature === "f2");
|
|
98
|
+
eq("per-phase wall-clock", f1.phases.build.seconds, 100);
|
|
99
|
+
eq("fix rounds counted", f1.fixRounds, 1);
|
|
100
|
+
eq("cycle rounds surface outside `surfaces`", f1.cycleRounds, 3);
|
|
101
|
+
eq("legacy lines fold into ONE batch", Object.keys(f2.surfaces).sort(), ["backend", "frontend"]);
|
|
102
|
+
eq("…with wall-clock = the slowest surface", f2.phases.build.seconds, 40);
|
|
103
|
+
eq("`error` counts as a surface failure", f1.surfaces.frontend.failures, 1);
|
|
104
|
+
eq("a REVISE verdict counts as a failure", f1.surfaces.backend.failures, 1);
|
|
105
|
+
check("newest feature first", m.features[0].feature === "f2", m.features[0].feature);
|
|
106
|
+
check("no metrics file ⇒ present:false", metrics({ projectRoot: scratch() }).present === false);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── doctor.js ────────────────────────────────────────────────────────────────
|
|
110
|
+
console.log("doctor.js — the /doctor port");
|
|
111
|
+
{
|
|
112
|
+
const spec = (fm) => `---\n${fm}\n---\n\n# x\n`;
|
|
113
|
+
const d = scratch();
|
|
114
|
+
mkdirSync(join(d, "specs"), { recursive: true });
|
|
115
|
+
writeFileSync(join(d, "specs", "a.md"), spec("feature_id: a\ntitle: A\nstatus: frozen\nbranch: feature/a"));
|
|
116
|
+
writeFileSync(join(d, "specs", "b.md"), spec("feature_id: b\nstatus: shipped # done"));
|
|
117
|
+
writeFileSync(join(d, "specs", "c.md"), "no front-matter at all");
|
|
118
|
+
writeFileSync(join(d, "specs", "_template.md"), spec("status: draft"));
|
|
119
|
+
const specs = scanSpecs(d);
|
|
120
|
+
eq("_template.md is excluded", specs.length, 3);
|
|
121
|
+
eq("front-matter fields are read", specs.find(s => s.id === "a").title, "A");
|
|
122
|
+
eq("a trailing comment is stripped from status", specs.find(s => s.id === "b").status, "shipped");
|
|
123
|
+
eq("no front-matter ⇒ id falls back to the filename", specs.find(s => s.file === "c.md").id, "c");
|
|
124
|
+
}
|
|
125
|
+
{
|
|
126
|
+
// A fully-wired synthetic project must come back green on the checks that can
|
|
127
|
+
// be computed from disk.
|
|
128
|
+
const g = scratch(); // stands in for ~/.claude
|
|
129
|
+
const d = scratch();
|
|
130
|
+
const gate = {
|
|
131
|
+
deny: ["x"], ask: ["y"], ask_on_default_branch: ["git push"], default_branch: "main",
|
|
132
|
+
preflight: { enabled: true, agents: ["review", "smoke"], max_age_minutes: 30 },
|
|
133
|
+
};
|
|
134
|
+
writeFileSync(join(d, "PIPELINE.md"), [
|
|
135
|
+
"```yaml pipeline-profile",
|
|
136
|
+
"name: Proj",
|
|
137
|
+
"retrieval:",
|
|
138
|
+
" provider: serena",
|
|
139
|
+
"surfaces:",
|
|
140
|
+
" - key: backend",
|
|
141
|
+
" path: apps/api",
|
|
142
|
+
" agent: backend",
|
|
143
|
+
"gate:",
|
|
144
|
+
" default_branch: main",
|
|
145
|
+
' deny: ["x"]',
|
|
146
|
+
' ask: ["y"]',
|
|
147
|
+
' ask_on_default_branch: ["git push"]',
|
|
148
|
+
" preflight:",
|
|
149
|
+
" enabled: true",
|
|
150
|
+
" agents: [review, smoke]",
|
|
151
|
+
" max_age_minutes: 30",
|
|
152
|
+
"```",
|
|
153
|
+
].join("\n"));
|
|
154
|
+
mkdirSync(join(d, ".claude", "agents"), { recursive: true });
|
|
155
|
+
mkdirSync(join(d, ".claude", "pipeline"), { recursive: true });
|
|
156
|
+
writeFileSync(join(d, ".claude", "pipeline", "VERSION"), "9.9.9\n");
|
|
157
|
+
writeFileSync(join(d, ".claude", "agents", "backend.md"), "x");
|
|
158
|
+
writeFileSync(join(d, ".claude", "gate-config.json"), JSON.stringify(gate));
|
|
159
|
+
writeFileSync(join(d, ".mcp.json"), JSON.stringify({ mcpServers: { serena: {} } }));
|
|
160
|
+
mkdirSync(join(d, ".claude", "workflows"), { recursive: true });
|
|
161
|
+
for (const w of ["review.js", "audit.js", "refactor.js", "cycle.js"]) {
|
|
162
|
+
writeFileSync(join(d, ".claude", "workflows", w), "x");
|
|
163
|
+
}
|
|
164
|
+
writeFileSync(join(d, ".claude", "agents", "profile-reader.md"), "x");
|
|
165
|
+
writeFileSync(join(d, ".claude", "settings.json"), JSON.stringify({
|
|
166
|
+
hooks: { PreToolUse: [{ matcher: "Bash|Task", hooks: [{ type: "command", command: 'py "C:\\x\\gate.py"' }] }] },
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
const by = (checks, id) => checks.find(c => c.id === id);
|
|
170
|
+
let s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
|
|
171
|
+
eq("profile parses ⇒ ok", by(s.checks, "profile").status, "ok");
|
|
172
|
+
eq("surface agent present ⇒ ok", by(s.checks, "agents").status, "ok");
|
|
173
|
+
eq("gate mirrors the profile ⇒ ok", by(s.checks, "gate").status, "ok");
|
|
174
|
+
eq("Windows-quoted hook command is recognised", by(s.checks, "hooks").status, "ok");
|
|
175
|
+
eq("retrieval wired in .mcp.json ⇒ ok", by(s.checks, "retrieval").status, "ok");
|
|
176
|
+
eq("workflows + profile-reader ⇒ ok", by(s.checks, "workflows").status, "ok");
|
|
177
|
+
|
|
178
|
+
// …and each check must actually FAIL when its precondition breaks.
|
|
179
|
+
writeFileSync(join(d, ".claude", "gate-config.json"),
|
|
180
|
+
JSON.stringify({ ...gate, preflight: { enabled: false } }));
|
|
181
|
+
s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
|
|
182
|
+
check("gate-config preflight drift is detected",
|
|
183
|
+
by(s.checks, "gate").status === "warn" && /preflight/.test(by(s.checks, "gate").detail),
|
|
184
|
+
by(s.checks, "gate").detail);
|
|
185
|
+
writeFileSync(join(d, ".claude", "gate-config.json"), JSON.stringify(gate));
|
|
186
|
+
|
|
187
|
+
writeFileSync(join(d, ".claude", "settings.json"), JSON.stringify({
|
|
188
|
+
hooks: { PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "python3 /x/gate.py" }] }] },
|
|
189
|
+
}));
|
|
190
|
+
s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
|
|
191
|
+
check("a Bash-only matcher is flagged (the 1.3.0 dead phase gate)",
|
|
192
|
+
by(s.checks, "hooks").status === "warn" && /Task/.test(by(s.checks, "hooks").detail),
|
|
193
|
+
by(s.checks, "hooks").detail);
|
|
194
|
+
|
|
195
|
+
writeFileSync(join(d, ".claude", "settings.json"), JSON.stringify({
|
|
196
|
+
hooks: { PreToolUse: [
|
|
197
|
+
{ matcher: "Bash|Task", hooks: [{ type: "command", command: "python3 /x/gate.py" }] },
|
|
198
|
+
{ matcher: "Bash|Task", hooks: [{ type: "command", command: 'py "/x/gate.py"' }] },
|
|
199
|
+
] },
|
|
200
|
+
}));
|
|
201
|
+
s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
|
|
202
|
+
check("a duplicate registration is flagged (it double-prompts)",
|
|
203
|
+
by(s.checks, "hooks").status === "warn" && /2×/.test(by(s.checks, "hooks").detail),
|
|
204
|
+
by(s.checks, "hooks").detail);
|
|
205
|
+
|
|
206
|
+
rmSync(join(d, ".mcp.json"));
|
|
207
|
+
writeFileSync(join(d, ".claude", "agents", "orphan.md"), "x");
|
|
208
|
+
s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
|
|
209
|
+
check("provider declared but never wired ⇒ warn",
|
|
210
|
+
by(s.checks, "retrieval").status === "warn", by(s.checks, "retrieval").detail);
|
|
211
|
+
check("an agent file with no surface ⇒ orphan warn",
|
|
212
|
+
by(s.checks, "agents").status === "warn" && /orphan/.test(by(s.checks, "agents").detail),
|
|
213
|
+
by(s.checks, "agents").detail);
|
|
214
|
+
|
|
215
|
+
const bare = scratch();
|
|
216
|
+
s = await state({ projectRoot: bare, globalDir: g, cliVersion: "9.9.9" });
|
|
217
|
+
eq("no PIPELINE.md ⇒ profile bad", by(s.checks, "profile").status, "bad");
|
|
218
|
+
eq("no core ⇒ core bad", by(s.checks, "core").status, "bad");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── kanban.js ────────────────────────────────────────────────────────────────
|
|
222
|
+
console.log("kanban.js — the Obsidian board");
|
|
223
|
+
{
|
|
224
|
+
const g = scratch(), vault = scratch(), d = scratch();
|
|
225
|
+
writeFileSync(join(d, "PIPELINE.md"), "```yaml pipeline-profile\nname: Proj\n```");
|
|
226
|
+
mkdirSync(join(vault, "Proj"), { recursive: true });
|
|
227
|
+
writeFileSync(join(vault, "Proj", "Tasks.md"), [
|
|
228
|
+
"---", "kanban-plugin: board", "---", "",
|
|
229
|
+
"## Spec", "", "- [ ] Do a thing #feat-a", "\t- a note", "",
|
|
230
|
+
"## Shipped", "", "- [x] Old #feat-z — PR #42", "",
|
|
231
|
+
"%% kanban:settings", "%%", "",
|
|
232
|
+
].join("\n"));
|
|
233
|
+
writeFileSync(join(g, "cohorte.config.yaml"), [
|
|
234
|
+
"kanban:", " enabled: true", " boards:", " Proj:", ' board: "Proj/Tasks.md"',
|
|
235
|
+
"obsidian:", ` vault_path: "${vault.replace(/\\/g, "/")}"`,
|
|
236
|
+
].join("\n"));
|
|
237
|
+
|
|
238
|
+
const k = kanban({ projectRoot: d, globalDir: g });
|
|
239
|
+
check("board resolves for the profile name", k.enabled === true, k.reason);
|
|
240
|
+
eq("columns parsed", k.columns.map(c => c.name), ["Spec", "Shipped"]);
|
|
241
|
+
eq("cards counted", k.total, 2);
|
|
242
|
+
eq("the #tag is extracted", k.columns[0].cards[0].tags, ["feat-a"]);
|
|
243
|
+
eq("the tag is stripped from the display text", k.columns[0].cards[0].text, "Do a thing");
|
|
244
|
+
eq("a checked card is done", k.columns[1].cards[0].done, true);
|
|
245
|
+
eq("a bare #<num> is read as a PR reference", k.columns[1].cards[0].prs.map(p => p.num), ["42"]);
|
|
246
|
+
check("the settings trailer is not parsed as a column",
|
|
247
|
+
!k.columns.some(c => /kanban:settings/.test(c.name)));
|
|
248
|
+
|
|
249
|
+
writeFileSync(join(g, "cohorte.config.yaml"), "kanban:\n enabled: false\n");
|
|
250
|
+
check("kanban disabled ⇒ enabled:false with a reason",
|
|
251
|
+
kanban({ projectRoot: d, globalDir: g }).enabled === false);
|
|
252
|
+
check("no config at all ⇒ enabled:false, never a throw",
|
|
253
|
+
kanban({ projectRoot: d, globalDir: scratch() }).enabled === false);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── fleet.js ─────────────────────────────────────────────────────────────────
|
|
257
|
+
console.log("fleet.js — the project registry");
|
|
258
|
+
{
|
|
259
|
+
const g = scratch(), p1 = scratch(), p2 = scratch();
|
|
260
|
+
fleet.ensureSeed(g, p1);
|
|
261
|
+
eq("seed adds the launch project", fleet.read(g), [p1]);
|
|
262
|
+
fleet.ensureSeed(g, p1);
|
|
263
|
+
eq("seeding twice does not duplicate", fleet.read(g).length, 1);
|
|
264
|
+
fleet.add(g, p2);
|
|
265
|
+
eq("add appends", fleet.read(g).length, 2);
|
|
266
|
+
fleet.remove(g, p2);
|
|
267
|
+
eq("remove drops it", fleet.read(g), [p1]);
|
|
268
|
+
|
|
269
|
+
let threw = null;
|
|
270
|
+
try { fleet.add(g, "relative/path"); } catch (e) { threw = e.message; }
|
|
271
|
+
check("a relative path is rejected with a clear message",
|
|
272
|
+
/must be absolute/.test(threw || ""), threw);
|
|
273
|
+
threw = null;
|
|
274
|
+
try { fleet.add(g, join(p1, "nope")); } catch (e) { threw = e.message; }
|
|
275
|
+
check("a non-existent path is rejected", /not found/.test(threw || ""), threw);
|
|
276
|
+
|
|
277
|
+
// legacy registry name is read, then migrated forward on the next write
|
|
278
|
+
const g2 = scratch();
|
|
279
|
+
writeFileSync(join(g2, "thebidouille-dashboard.json"), JSON.stringify({ projects: [p1] }));
|
|
280
|
+
eq("the pre-rename registry is still read", fleet.read(g2), [p1]);
|
|
281
|
+
|
|
282
|
+
const b = fleet.browse(p1);
|
|
283
|
+
check("browse lists a directory", Array.isArray(b.dirs) && b.parent !== null);
|
|
284
|
+
writeFileSync(join(p1, "PIPELINE.md"), "x");
|
|
285
|
+
check("browse flags a pipeline project", fleet.browse(p1).isProject === true);
|
|
286
|
+
const missing = fleet.browse(join(p1, "does-not-exist"));
|
|
287
|
+
check("browse reports an unreadable dir in the body (not a throw)",
|
|
288
|
+
!!missing.error && missing.dirs.length === 0, JSON.stringify(missing));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── index.js — the HTTP guards ───────────────────────────────────────────────
|
|
292
|
+
console.log("index.js — HTTP guards");
|
|
293
|
+
{
|
|
294
|
+
const freePort = await new Promise((res) => {
|
|
295
|
+
const s = net.createServer();
|
|
296
|
+
s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => res(port)); });
|
|
297
|
+
});
|
|
298
|
+
const home = scratch(); // stands in for the user's home
|
|
299
|
+
const globalDir = join(home, ".claude"); // …so <home>/.claude IS the global core
|
|
300
|
+
mkdirSync(globalDir, { recursive: true });
|
|
301
|
+
const proj = scratch();
|
|
302
|
+
const start = require(join(root, "dashboard/server/index.js"));
|
|
303
|
+
start({ projectRoot: proj, globalDir, port: freePort, host: "127.0.0.1", openBrowser: false, pkgRoot: root, version: "9.9.9" });
|
|
304
|
+
const base = `http://127.0.0.1:${freePort}`;
|
|
305
|
+
const post = (body, headers = { "content-type": "application/json" }) =>
|
|
306
|
+
fetch(`${base}/api/action`, { method: "POST", headers, body: JSON.stringify(body) });
|
|
307
|
+
|
|
308
|
+
// `Host` is a forbidden header name for fetch/undici — it silently drops it, so
|
|
309
|
+
// a fetch-based assertion here passes against a server with NO guard at all.
|
|
310
|
+
// Speak raw HTTP instead.
|
|
311
|
+
const rawStatus = (host) => new Promise((res, rej) => {
|
|
312
|
+
const s = net.connect(freePort, "127.0.0.1", () => {
|
|
313
|
+
s.write(`GET /api/fleet HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`);
|
|
314
|
+
});
|
|
315
|
+
let buf = "";
|
|
316
|
+
s.on("data", (c) => { buf += c; });
|
|
317
|
+
s.on("end", () => res(Number((buf.match(/^HTTP\/1\.1 (\d+)/) || [])[1])));
|
|
318
|
+
s.on("error", rej);
|
|
319
|
+
});
|
|
320
|
+
eq("a forged Host header is rejected (DNS rebinding)", await rawStatus("evil.example.com"), 403);
|
|
321
|
+
eq("…while a loopback Host with a port passes", await rawStatus(`127.0.0.1:${freePort}`), 200);
|
|
322
|
+
eq("…and a bracketed IPv6 loopback passes", await rawStatus(`[::1]:${freePort}`), 200);
|
|
323
|
+
eq("…and bare 'localhost' passes", await rawStatus("localhost"), 200);
|
|
324
|
+
|
|
325
|
+
const csrf = await fetch(`${base}/api/projects`, {
|
|
326
|
+
method: "POST", headers: { "content-type": "text/plain" }, body: "path=/x",
|
|
327
|
+
});
|
|
328
|
+
eq("a state-changing request without JSON content-type is rejected (CSRF)", csrf.status, 403);
|
|
329
|
+
|
|
330
|
+
eq("a GET on the API still works", (await fetch(`${base}/api/fleet`)).status, 200);
|
|
331
|
+
|
|
332
|
+
const reset = await post({ action: "reset", project: home });
|
|
333
|
+
eq("reset refuses a project whose .claude IS the global core", reset.status, 400);
|
|
334
|
+
check("…and says why", /shared global core/.test((await reset.json()).error));
|
|
335
|
+
|
|
336
|
+
const badPath = await post({ action: "install", project: join(proj, "nope") });
|
|
337
|
+
eq("install refuses a non-existent project path", badPath.status, 400);
|
|
338
|
+
|
|
339
|
+
const badAction = await post({ action: "rm -rf" });
|
|
340
|
+
eq("an unknown action is rejected", badAction.status, 400);
|
|
341
|
+
|
|
342
|
+
const badCmd = await post({ action: "claude", command: "/evil", project: proj });
|
|
343
|
+
eq("a non-whitelisted slash command is rejected", badCmd.status, 400);
|
|
344
|
+
|
|
345
|
+
eq("a missing hashed asset 404s (never index.html)",
|
|
346
|
+
(await fetch(`${base}/assets/index-DEADBEEF.js`)).status, 404);
|
|
347
|
+
eq("a malformed percent-escape is a 400, not a 500",
|
|
348
|
+
(await fetch(`${base}/%`)).status, 400);
|
|
349
|
+
eq("an unknown API route 404s", (await fetch(`${base}/api/nope`)).status, 404);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
for (const d of tmps) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
353
|
+
console.log("");
|
|
354
|
+
if (failures) { console.error(`test-dashboard: ${failures} failure(s)`); process.exit(1); }
|
|
355
|
+
console.log("test-dashboard: OK");
|
|
356
|
+
process.exit(0); // the HTTP server has no handle to close
|