@ijfw/memory-server 1.4.4 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
- package/package.json +1 -1
- package/src/active-extension-writer.js +144 -64
- package/src/api-client.js +43 -5
- package/src/audit-roster.js +80 -5
- package/src/blackboard.js +298 -6
- package/src/cli-run.js +33 -5
- package/src/codex-agents.js +96 -5
- package/src/cost/aggregator.js +39 -9
- package/src/cost/pricing.js +57 -0
- package/src/cost/readers/gemini.js +1 -1
- package/src/cross-audit-chunker.js +189 -0
- package/src/cross-dispatcher.js +124 -21
- package/src/cross-orchestrator-cli.js +550 -14
- package/src/cross-orchestrator.js +1016 -17
- package/src/cross-project-search.js +195 -9
- package/src/dashboard-client-waves.html +304 -0
- package/src/dashboard-client.html +5 -1
- package/src/dashboard-server.js +73 -0
- package/src/deploy-alerts.js +150 -0
- package/src/design/iframe-bridge.js +242 -0
- package/src/design-companion.js +144 -0
- package/src/dispatch/checkpoint-cli.js +97 -0
- package/src/dispatch/colon-syntax.js +81 -1
- package/src/dispatch/extension.js +26 -2
- package/src/dispatch/registry-cli.js +4 -1
- package/src/dispatch/wave-cli.js +201 -6
- package/src/dispatch/worktree-cli.js +40 -0
- package/src/dispatch-planner.js +97 -2
- package/src/dream/runner.mjs +47 -11
- package/src/dream/stage-runner.js +40 -0
- package/src/dream/state-file.js +102 -0
- package/src/extension-installer.js +70 -24
- package/src/extension-quota-tracker.js +4 -2
- package/src/extension-registry.js +289 -35
- package/src/feedback-detector.js +26 -0
- package/src/fs-lock.js +259 -7
- package/src/gate-result.js +95 -1
- package/src/hero-line.js +86 -5
- package/src/intent-router.js +35 -0
- package/src/lib/a11y-contract.js +117 -0
- package/src/lib/atomic-io.js +29 -8
- package/src/lib/cache-keepalive.js +150 -0
- package/src/lib/jsonl-rotation.js +104 -0
- package/src/lib/lighthouse-pillar.js +121 -0
- package/src/lib/llm-call.js +121 -0
- package/src/lib/playwright-baseline.js +205 -0
- package/src/lib/rekor-bridge.js +221 -0
- package/src/lib/repo-map.js +392 -0
- package/src/lib/shasum-verify.js +164 -0
- package/src/lib/sketches-gc.js +132 -0
- package/src/lib/tmp-suffix.js +62 -0
- package/src/lib/ui-review-runner.js +554 -0
- package/src/lib/uispec-drift.js +301 -0
- package/src/lib/uispec-intake.js +381 -0
- package/src/lib/worktree-guards.js +118 -0
- package/src/lib/worktree-recovery.js +100 -0
- package/src/memory/auto-linker.js +152 -0
- package/src/memory/benchmark.js +498 -0
- package/src/memory/dedup.js +126 -0
- package/src/memory/embedding-cache.js +136 -0
- package/src/memory/fact-extractor.js +168 -0
- package/src/memory/fts5.js +65 -1
- package/src/memory/migrations/004-bitemporal.js +91 -0
- package/src/memory/migrations/005-vector-cache.js +61 -0
- package/src/memory/migrations/006-obsidian-graph.js +46 -0
- package/src/memory/migrations/007-skill-telemetry.js +24 -0
- package/src/memory/migrations/008-write-provenance.js +41 -0
- package/src/memory/obsidian-parser.js +91 -0
- package/src/memory/query-dataview.js +86 -0
- package/src/memory/search.js +10 -0
- package/src/memory/temporal.js +529 -0
- package/src/memory/tokenize.js +10 -0
- package/src/memory-facts-handler.js +37 -0
- package/src/memory-feedback.js +260 -2
- package/src/model-refresh.js +292 -0
- package/src/observability/cost-anomaly.js +166 -0
- package/src/observability/evaluator-checkpoint-contract.js +117 -0
- package/src/observability/trace-id.js +163 -0
- package/src/orchestrator/agents-md-blackboard.js +152 -0
- package/src/orchestrator/checkpoint-contract.md +140 -0
- package/src/orchestrator/debug-trident.js +570 -0
- package/src/orchestrator/merge-block-aware.js +350 -0
- package/src/orchestrator/plan-checker.js +475 -0
- package/src/orchestrator/post-done-runner.js +249 -0
- package/src/orchestrator/review.js +38 -3
- package/src/orchestrator/runtime-loop.js +430 -0
- package/src/orchestrator/skill-telemetry-sink.js +29 -0
- package/src/orchestrator/skill-telemetry.js +37 -0
- package/src/orchestrator/state-events.js +459 -0
- package/src/orchestrator/state-sdk.js +1764 -0
- package/src/orchestrator/status-protocol.js +84 -17
- package/src/orchestrator/subagent-telemetry.js +452 -0
- package/src/orchestrator/termination.js +160 -0
- package/src/orchestrator/verification-gate.js +200 -16
- package/src/orchestrator/wave-state.js +332 -23
- package/src/orchestrator/worktree-provision.js +77 -0
- package/src/override-use-registry.js +111 -5
- package/src/receipts.js +36 -4
- package/src/recovery/checkpoint.js +56 -3
- package/src/recovery/code-fixer.js +656 -0
- package/src/recovery/truncation.js +317 -0
- package/src/redactor.js +75 -6
- package/src/runtime-mediator.js +15 -0
- package/src/sanitizer.js +10 -0
- package/src/search-hybrid.js +139 -0
- package/src/server.js +603 -59
- package/src/swarm/worktree.js +27 -4
- package/src/swarm-config.js +94 -17
- package/src/team/domain-templates/book.json +51 -0
- package/src/team/domain-templates/business.json +41 -0
- package/src/team/domain-templates/content.json +50 -0
- package/src/team/domain-templates/design.json +44 -0
- package/src/team/domain-templates/research.json +41 -0
- package/src/team/domain-templates/software.json +40 -0
- package/src/team/generator.js +278 -3
- package/src/team/modify.js +203 -0
- package/src/team/schemas.js +48 -0
- package/src/update-apply.js +19 -3
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// v1.5.0 audit-LOW-update-#13: single helper for atomic-write tmp-suffix generation.
|
|
2
|
+
//
|
|
3
|
+
// Before this module, six call sites hand-rolled essentially the same pattern:
|
|
4
|
+
// `${path}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`
|
|
5
|
+
// or:
|
|
6
|
+
// `${path}.tmp.${randomBytes(4).toString('hex')}`
|
|
7
|
+
// or:
|
|
8
|
+
// `${path}.tmp-${process.pid}-${Date.now()}`
|
|
9
|
+
//
|
|
10
|
+
// The variations carry no semantic difference -- all three are "unique-enough
|
|
11
|
+
// per-process collision-resistant temporary filename" -- but the drift made
|
|
12
|
+
// audit + diff review noisy and easy to typo. This helper consolidates the
|
|
13
|
+
// pattern and standardises the wide form (pid + 8 random bytes hex) so all
|
|
14
|
+
// atomic writes share one collision-resistance budget.
|
|
15
|
+
//
|
|
16
|
+
// Backwards-compat: existing tmp-files that don't follow this exact pattern
|
|
17
|
+
// are STILL cleaned up by their callers via the same `if (existsSync(tmp))
|
|
18
|
+
// unlinkSync(tmp)` rollback path -- this helper only changes the naming
|
|
19
|
+
// scheme on new writes, not the rollback contract.
|
|
20
|
+
|
|
21
|
+
import { randomBytes } from 'node:crypto';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build a temporary-file suffix for atomic writes (write-tmp + rename).
|
|
25
|
+
*
|
|
26
|
+
* @param {object} [opts]
|
|
27
|
+
* @param {number} [opts.bytes=8] Number of random bytes in the suffix (each
|
|
28
|
+
* byte becomes 2 hex chars). 8 bytes = 64 bits
|
|
29
|
+
* of entropy = 2^32 writes per pid before a
|
|
30
|
+
* 50% collision probability under the birthday
|
|
31
|
+
* bound — well past any plausible per-process
|
|
32
|
+
* rate. 4 supported for legacy parity.
|
|
33
|
+
* @param {boolean} [opts.includePid=true] Prefix with `<pid>.` to make stray
|
|
34
|
+
* tmp leftovers diagnosable to the owning
|
|
35
|
+
* process. Disable only if pid would be
|
|
36
|
+
* privacy-sensitive (none of IJFW's writes
|
|
37
|
+
* qualify, hence default true).
|
|
38
|
+
* @returns {string} suffix WITHOUT a leading `.tmp.` — caller composes the
|
|
39
|
+
* full tmp path (kept that way so callers can pick `.tmp.`,
|
|
40
|
+
* `.tmp-`, or any other separator the surrounding code uses).
|
|
41
|
+
*/
|
|
42
|
+
export function tmpSuffix(opts = {}) {
|
|
43
|
+
const bytes = Number.isFinite(opts.bytes) && opts.bytes > 0 ? Math.floor(opts.bytes) : 8;
|
|
44
|
+
const includePid = opts.includePid !== false;
|
|
45
|
+
const rand = randomBytes(bytes).toString('hex');
|
|
46
|
+
return includePid ? `${process.pid}.${rand}` : rand;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Convenience: build the full tmp path for a target file using the canonical
|
|
51
|
+
* `.tmp.<pid>.<random>` shape. Use this when you want exactly the standard
|
|
52
|
+
* pattern; for non-standard separators (e.g. existing call sites that use
|
|
53
|
+
* `.tmp-<pid>-<date>`) keep building the path inline + only call tmpSuffix()
|
|
54
|
+
* for the random portion.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} targetPath
|
|
57
|
+
* @param {object} [opts] Forwarded to tmpSuffix().
|
|
58
|
+
* @returns {string} `${targetPath}.tmp.${suffix}`
|
|
59
|
+
*/
|
|
60
|
+
export function tmpPathFor(targetPath, opts = {}) {
|
|
61
|
+
return `${targetPath}.tmp.${tmpSuffix(opts)}`;
|
|
62
|
+
}
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
// ui-review-runner.js -- v1.5.0 wire-W1.D + W1.E.
|
|
2
|
+
//
|
|
3
|
+
// Production wire-up for the 7 design libs (uispec-intake, uispec-drift,
|
|
4
|
+
// a11y-contract, lighthouse-pillar, playwright-baseline, sketches-gc) plus
|
|
5
|
+
// the 7-pillar visual audit declared in `claude/agents/ijfw-ui-auditor.md`.
|
|
6
|
+
//
|
|
7
|
+
// Before W1.D these libraries shipped with isolated tests but ZERO callers.
|
|
8
|
+
// The auditor agent's "wave dispatch one subagent per pillar" was declared
|
|
9
|
+
// in markdown but never implemented in runtime. This runner closes both
|
|
10
|
+
// gaps:
|
|
11
|
+
//
|
|
12
|
+
// - Builds a per-pillar grader function that calls into the relevant lib.
|
|
13
|
+
// - Dispatches all 7 graders in parallel via Promise.all (W1.E).
|
|
14
|
+
// - Assembles a single UI-REVIEW.md from the per-pillar verdicts.
|
|
15
|
+
// - Returns a structured result so the caller can branch on top-level
|
|
16
|
+
// verdict (PASS / FLAG / BLOCK) without re-parsing markdown.
|
|
17
|
+
//
|
|
18
|
+
// Each grader function is intentionally small + boundary-pure: it reads
|
|
19
|
+
// from `spec` + `sourceScope` + `peerInputs` and emits
|
|
20
|
+
// `{ pillar, verdict, findings, startedAt, finishedAt, evidence?, reason? }`.
|
|
21
|
+
// Heavy lifting (Lighthouse, axe, Playwright) is done OUT-OF-PROCESS by
|
|
22
|
+
// peer tools (chrome-devtools-mcp's lighthouse_audit / axe runner, the
|
|
23
|
+
// user's own Playwright). The runner consumes the peer outputs via
|
|
24
|
+
// `peerInputs` and adapts them through the evaluator libs.
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
27
|
+
import { join, dirname, extname } from 'node:path';
|
|
28
|
+
import {
|
|
29
|
+
parseUISpec,
|
|
30
|
+
scanCodeForTailwind,
|
|
31
|
+
diffPaletteDrift,
|
|
32
|
+
} from './uispec-drift.js';
|
|
33
|
+
import {
|
|
34
|
+
evaluateA11y,
|
|
35
|
+
DEFAULT_A11Y_TARGET,
|
|
36
|
+
DEFAULT_MAX_VIOLATIONS,
|
|
37
|
+
} from './a11y-contract.js';
|
|
38
|
+
import { evaluateLighthouse, LIGHTHOUSE_THRESHOLDS } from './lighthouse-pillar.js';
|
|
39
|
+
import { compareToBaseline } from './playwright-baseline.js';
|
|
40
|
+
import { runSketchesGc } from './sketches-gc.js';
|
|
41
|
+
|
|
42
|
+
// Pillar order is canonical -- the auditor agent spec enumerates them in
|
|
43
|
+
// this exact sequence. The runner emits per-pillar sections in the same
|
|
44
|
+
// order so the rendered UI-REVIEW.md is stable across runs.
|
|
45
|
+
export const PILLARS = Object.freeze([
|
|
46
|
+
'layout',
|
|
47
|
+
'typography',
|
|
48
|
+
'color',
|
|
49
|
+
'spacing',
|
|
50
|
+
'components',
|
|
51
|
+
'interaction',
|
|
52
|
+
'security',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const PILLAR_TITLES = Object.freeze({
|
|
56
|
+
layout: '1. Layout & Hierarchy',
|
|
57
|
+
typography: '2. Typography & Reading Flow',
|
|
58
|
+
color: '3. Color & Contrast',
|
|
59
|
+
spacing: '4. Spacing & Rhythm',
|
|
60
|
+
components: '5. Component Consistency',
|
|
61
|
+
interaction: '6. Interaction & Motion',
|
|
62
|
+
security: '7. Security & Headers',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const VERDICT_PASS = 'PASS';
|
|
66
|
+
const VERDICT_FLAG = 'FLAG';
|
|
67
|
+
const VERDICT_BLOCK = 'BLOCK';
|
|
68
|
+
const VERDICT_MISSING = 'spec-section-missing';
|
|
69
|
+
|
|
70
|
+
// Source-walking — same extension set as the auditor agent's `find` step.
|
|
71
|
+
const SOURCE_EXTS = new Set([
|
|
72
|
+
'.tsx', '.jsx', '.ts', '.js', '.css', '.scss',
|
|
73
|
+
'.html', '.vue', '.svelte', '.md', '.mdx',
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
function walkSourceFiles(scopes, projectRoot, opts = {}) {
|
|
77
|
+
const maxFiles = typeof opts.maxFiles === 'number' ? opts.maxFiles : 2000;
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const scope of scopes) {
|
|
80
|
+
const abs = scope.startsWith('/') ? scope : join(projectRoot, scope);
|
|
81
|
+
if (!existsSync(abs)) continue;
|
|
82
|
+
const stack = [abs];
|
|
83
|
+
while (stack.length > 0 && out.length < maxFiles) {
|
|
84
|
+
const dir = stack.pop();
|
|
85
|
+
let entries;
|
|
86
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
87
|
+
for (const ent of entries) {
|
|
88
|
+
if (ent.name.startsWith('.')) continue;
|
|
89
|
+
if (ent.name === 'node_modules') continue;
|
|
90
|
+
const p = join(dir, ent.name);
|
|
91
|
+
if (ent.isDirectory()) { stack.push(p); continue; }
|
|
92
|
+
if (!ent.isFile()) continue;
|
|
93
|
+
if (!SOURCE_EXTS.has(extname(ent.name).toLowerCase())) continue;
|
|
94
|
+
out.push(p);
|
|
95
|
+
if (out.length >= maxFiles) break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readSafe(file) {
|
|
103
|
+
try { return readFileSync(file, 'utf8'); } catch { return ''; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Pillar graders. Each returns the same shape:
|
|
108
|
+
// { pillar, verdict, findings, evidence?, reason?, startedAt, finishedAt }
|
|
109
|
+
// findings: Array<{ severity, text, file?, line? }>
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
function gradeLayout({ spec, files, projectRoot: _projectRoot }) {
|
|
113
|
+
const startedAt = Date.now();
|
|
114
|
+
const findings = [];
|
|
115
|
+
// Surface presence is hard to derive from a spec we only parse loosely.
|
|
116
|
+
// We default to FLAG when the spec is silent on layout (a stronger signal
|
|
117
|
+
// would require parseUISpec to surface layout fields). If the spec text
|
|
118
|
+
// mentions `## 1.` (Layout) at all, we treat it as covered enough to PASS.
|
|
119
|
+
const specText = spec.__rawText || '';
|
|
120
|
+
if (!/^##\s*1\b/m.test(specText) && !/Layout\s*&\s*Hierarchy/i.test(specText)) {
|
|
121
|
+
return {
|
|
122
|
+
pillar: 'layout', verdict: VERDICT_MISSING, findings: [],
|
|
123
|
+
reason: 'UI-SPEC has no Layout section (## 1 ...)',
|
|
124
|
+
startedAt, finishedAt: Date.now(),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// Presence check: surfaces (any html/tsx file) must exist somewhere in scope.
|
|
128
|
+
const surfaces = files.filter((f) => /\.(tsx|jsx|html|vue|svelte)$/i.test(f));
|
|
129
|
+
if (surfaces.length === 0) {
|
|
130
|
+
findings.push({ severity: 'high', text: 'no surface files (.tsx/.jsx/.html/.vue/.svelte) found in source_scope' });
|
|
131
|
+
return { pillar: 'layout', verdict: VERDICT_BLOCK, findings, startedAt, finishedAt: Date.now() };
|
|
132
|
+
}
|
|
133
|
+
return { pillar: 'layout', verdict: VERDICT_PASS, findings, evidence: `${surfaces.length} surface files in scope`, startedAt, finishedAt: Date.now() };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function gradeTypography({ spec, files }) {
|
|
137
|
+
const startedAt = Date.now();
|
|
138
|
+
const findings = [];
|
|
139
|
+
const specText = spec.__rawText || '';
|
|
140
|
+
if (!/^##\s*2\b/m.test(specText) && !/Typography/i.test(specText)) {
|
|
141
|
+
return { pillar: 'typography', verdict: VERDICT_MISSING, findings: [], reason: 'UI-SPEC has no Typography section', startedAt, finishedAt: Date.now() };
|
|
142
|
+
}
|
|
143
|
+
// Cheap check: at least one font-family declaration should exist in scope.
|
|
144
|
+
let fontDecls = 0;
|
|
145
|
+
for (const f of files.slice(0, 200)) {
|
|
146
|
+
const txt = readSafe(f);
|
|
147
|
+
if (/font-family\s*:/i.test(txt)) fontDecls += 1;
|
|
148
|
+
if (fontDecls > 0) break;
|
|
149
|
+
}
|
|
150
|
+
if (fontDecls === 0) {
|
|
151
|
+
findings.push({ severity: 'med', text: 'no `font-family` declaration found in scope (spec defines typography but source omits it)' });
|
|
152
|
+
return { pillar: 'typography', verdict: VERDICT_FLAG, findings, startedAt, finishedAt: Date.now() };
|
|
153
|
+
}
|
|
154
|
+
return { pillar: 'typography', verdict: VERDICT_PASS, findings, evidence: 'font-family declared', startedAt, finishedAt: Date.now() };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function gradeColor({ spec, sourceScope, projectRoot }) {
|
|
158
|
+
const startedAt = Date.now();
|
|
159
|
+
const findings = [];
|
|
160
|
+
if (!spec.paletteHex || spec.paletteHex.length === 0) {
|
|
161
|
+
return { pillar: 'color', verdict: VERDICT_MISSING, findings: [], reason: 'UI-SPEC has no color tokens (## 3 Color)', startedAt, finishedAt: Date.now() };
|
|
162
|
+
}
|
|
163
|
+
// Run the existing drift detector across the scope. `diffPaletteDrift`
|
|
164
|
+
// returns an array of { type, value, severity, declared } findings. A
|
|
165
|
+
// finding here means a color in source that the spec does NOT declare.
|
|
166
|
+
//
|
|
167
|
+
// v1.5.0 r20-HIGH fix: an error inside scanCodeForTailwind / diffPaletteDrift
|
|
168
|
+
// previously silently set drift=[] -- the color pillar would then PASS
|
|
169
|
+
// even though we never actually scanned. Surface the failure as a FLAG
|
|
170
|
+
// finding AND log to stderr; the pillar's verdict reflects real
|
|
171
|
+
// coverage instead of a false-positive PASS.
|
|
172
|
+
let drift = [];
|
|
173
|
+
let driftError = null;
|
|
174
|
+
try {
|
|
175
|
+
const scan = scanCodeForTailwind(sourceScope, { projectRoot });
|
|
176
|
+
drift = diffPaletteDrift(spec, scan);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
driftError = err && err.message ? err.message : String(err);
|
|
179
|
+
drift = [];
|
|
180
|
+
process.stderr.write(
|
|
181
|
+
`ijfw ui-review: gradeColor drift detection failed (${driftError}). ` +
|
|
182
|
+
`Color pillar will FLAG instead of PASS until the scan succeeds.\n`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const blockers = drift.filter((d) => d.severity === 'block');
|
|
187
|
+
const flagsOnly = drift.filter((d) => d.severity === 'flag');
|
|
188
|
+
|
|
189
|
+
for (const d of drift.slice(0, 25)) {
|
|
190
|
+
findings.push({
|
|
191
|
+
severity: d.severity === 'block' ? 'high' : 'med',
|
|
192
|
+
text: `unauthorized ${d.type} in source: ${d.value}`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let verdict = VERDICT_PASS;
|
|
197
|
+
if (blockers.length > 0) verdict = VERDICT_BLOCK;
|
|
198
|
+
else if (flagsOnly.length > 0) verdict = VERDICT_FLAG;
|
|
199
|
+
// r20-HIGH: when the scan itself failed, surface FLAG even with no
|
|
200
|
+
// drift findings so the user doesn't read a false-PASS.
|
|
201
|
+
if (driftError) {
|
|
202
|
+
verdict = VERDICT_FLAG;
|
|
203
|
+
findings.unshift({ severity: 'med', text: `drift scan failed: ${driftError}` });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
pillar: 'color',
|
|
208
|
+
verdict,
|
|
209
|
+
findings,
|
|
210
|
+
evidence: driftError
|
|
211
|
+
? `scan failed (${driftError}); ${drift.length} drift findings observed before failure`
|
|
212
|
+
: `${drift.length} drift findings (${blockers.length} block / ${flagsOnly.length} flag)`,
|
|
213
|
+
startedAt, finishedAt: Date.now(),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function gradeSpacing({ spec, files }) {
|
|
218
|
+
const startedAt = Date.now();
|
|
219
|
+
const findings = [];
|
|
220
|
+
const specText = spec.__rawText || '';
|
|
221
|
+
if (!/^##\s*4\b/m.test(specText) && !/Spacing/i.test(specText)) {
|
|
222
|
+
return { pillar: 'spacing', verdict: VERDICT_MISSING, findings: [], reason: 'UI-SPEC has no Spacing section', startedAt, finishedAt: Date.now() };
|
|
223
|
+
}
|
|
224
|
+
// Quick smell test: arbitrary-value Tailwind brackets (e.g. `p-[17px]`)
|
|
225
|
+
// suggest the scale wasn't honored.
|
|
226
|
+
let arbitraryCount = 0;
|
|
227
|
+
for (const f of files.slice(0, 300)) {
|
|
228
|
+
const txt = readSafe(f);
|
|
229
|
+
const m = txt.match(/\b[pm][trblxy]?-\[[^\]]+\]/g);
|
|
230
|
+
if (m) arbitraryCount += m.length;
|
|
231
|
+
}
|
|
232
|
+
if (arbitraryCount > 10) {
|
|
233
|
+
findings.push({ severity: 'med', text: `${arbitraryCount} arbitrary spacing values in scope; spec defines a scale` });
|
|
234
|
+
return { pillar: 'spacing', verdict: VERDICT_FLAG, findings, startedAt, finishedAt: Date.now() };
|
|
235
|
+
}
|
|
236
|
+
return { pillar: 'spacing', verdict: VERDICT_PASS, findings, evidence: `${arbitraryCount} arbitrary values (<= threshold)`, startedAt, finishedAt: Date.now() };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function gradeComponents({ spec, files }) {
|
|
240
|
+
const startedAt = Date.now();
|
|
241
|
+
const findings = [];
|
|
242
|
+
const specText = spec.__rawText || '';
|
|
243
|
+
if (!/^##\s*5\b/m.test(specText) && !/Component/i.test(specText)) {
|
|
244
|
+
return { pillar: 'components', verdict: VERDICT_MISSING, findings: [], reason: 'UI-SPEC has no Components section', startedAt, finishedAt: Date.now() };
|
|
245
|
+
}
|
|
246
|
+
// Spec is silent on the exact component set in the parsed shape, so we
|
|
247
|
+
// just confirm that SOME components exist in scope.
|
|
248
|
+
let componentDecls = 0;
|
|
249
|
+
for (const f of files.slice(0, 300)) {
|
|
250
|
+
const txt = readSafe(f);
|
|
251
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- scans developer-authored source files on local disk; bounded by file line length, not exploitable
|
|
252
|
+
if (/export\s+(?:default\s+)?(?:function|class|const)\s+[A-Z]/.test(txt)) componentDecls += 1;
|
|
253
|
+
}
|
|
254
|
+
if (componentDecls === 0) {
|
|
255
|
+
findings.push({ severity: 'med', text: 'no exported component declarations found in scope' });
|
|
256
|
+
return { pillar: 'components', verdict: VERDICT_FLAG, findings, startedAt, finishedAt: Date.now() };
|
|
257
|
+
}
|
|
258
|
+
return { pillar: 'components', verdict: VERDICT_PASS, findings, evidence: `${componentDecls} component declarations`, startedAt, finishedAt: Date.now() };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function gradeInteraction({ spec, files, peerInputs }) {
|
|
262
|
+
const startedAt = Date.now();
|
|
263
|
+
const findings = [];
|
|
264
|
+
const specText = spec.__rawText || '';
|
|
265
|
+
if (!/^##\s*6\b/m.test(specText) && !/Interaction|Motion/i.test(specText)) {
|
|
266
|
+
return { pillar: 'interaction', verdict: VERDICT_MISSING, findings: [], reason: 'UI-SPEC has no Interaction section', startedAt, finishedAt: Date.now() };
|
|
267
|
+
}
|
|
268
|
+
// Floor check: every interactive surface should declare :focus styling.
|
|
269
|
+
// Cheap heuristic: count :focus mentions in scope; if zero, BLOCK (a11y floor).
|
|
270
|
+
let focusMentions = 0;
|
|
271
|
+
let interactiveDecls = 0;
|
|
272
|
+
for (const f of files.slice(0, 300)) {
|
|
273
|
+
const txt = readSafe(f);
|
|
274
|
+
if (/:focus(?:-visible)?\b/.test(txt)) focusMentions += 1;
|
|
275
|
+
if (/<(?:button|a|input|select|textarea)\b/i.test(txt)) interactiveDecls += 1;
|
|
276
|
+
}
|
|
277
|
+
if (interactiveDecls > 0 && focusMentions === 0) {
|
|
278
|
+
findings.push({ severity: 'high', text: ':focus styling not found in any file with interactive elements (WCAG floor violation)' });
|
|
279
|
+
return { pillar: 'interaction', verdict: VERDICT_BLOCK, findings, startedAt, finishedAt: Date.now() };
|
|
280
|
+
}
|
|
281
|
+
// Playwright baseline check (optional peer)
|
|
282
|
+
if (peerInputs && peerInputs.playwright) {
|
|
283
|
+
try {
|
|
284
|
+
const cmp = compareToBaseline(peerInputs.playwright);
|
|
285
|
+
if (cmp && cmp.pass === false) {
|
|
286
|
+
findings.push({ severity: 'med', text: `playwright baseline diff: ${cmp.reason || 'changed'}` });
|
|
287
|
+
}
|
|
288
|
+
} catch { /* peer tool optional */ }
|
|
289
|
+
}
|
|
290
|
+
// r21-HIGH-1: derive the verdict from findings instead of returning PASS
|
|
291
|
+
// unconditionally. A recorded playwright baseline diff (or any other
|
|
292
|
+
// finding) must downgrade the pillar — a true PASS means zero findings.
|
|
293
|
+
let verdict = VERDICT_PASS;
|
|
294
|
+
if (findings.some((f) => f.severity === 'high')) verdict = VERDICT_BLOCK;
|
|
295
|
+
else if (findings.length > 0) verdict = VERDICT_FLAG;
|
|
296
|
+
return { pillar: 'interaction', verdict, findings, evidence: `${focusMentions} :focus / ${interactiveDecls} interactive surfaces`, startedAt, finishedAt: Date.now() };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function gradeSecurity({ spec, files, peerInputs }) {
|
|
300
|
+
const startedAt = Date.now();
|
|
301
|
+
const findings = [];
|
|
302
|
+
// a11y is part of the security pillar per the v1.5.0 7-pillar enumeration.
|
|
303
|
+
if (peerInputs && peerInputs.axe !== undefined) {
|
|
304
|
+
// r21-MED: isolate evaluator failures — a malformed axe peer input must
|
|
305
|
+
// not throw out of the grader and reject the whole Promise.all review.
|
|
306
|
+
try {
|
|
307
|
+
const a11y = evaluateA11y(peerInputs.axe, {
|
|
308
|
+
target: spec.a11yTarget || DEFAULT_A11Y_TARGET,
|
|
309
|
+
maxViolations: spec.maxViolations != null ? spec.maxViolations : DEFAULT_MAX_VIOLATIONS,
|
|
310
|
+
});
|
|
311
|
+
if (a11y.pass === false) {
|
|
312
|
+
findings.push({ severity: 'high', text: `a11y: ${a11y.count} violations exceed budget ${a11y.maxViolations}` });
|
|
313
|
+
}
|
|
314
|
+
} catch (err) {
|
|
315
|
+
findings.push({ severity: 'med', text: `a11y evaluation failed: ${err && err.message ? err.message : String(err)}` });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// CSP / inline-handler smell tests
|
|
319
|
+
let inlineHandlers = 0;
|
|
320
|
+
let unsafeCspHits = 0;
|
|
321
|
+
for (const f of files.slice(0, 300)) {
|
|
322
|
+
const txt = readSafe(f);
|
|
323
|
+
if (/\bon(?:click|load|error|change|input|submit)\s*=\s*["']/.test(txt)) inlineHandlers += 1;
|
|
324
|
+
if (/unsafe-(?:inline|eval)/i.test(txt)) unsafeCspHits += 1;
|
|
325
|
+
}
|
|
326
|
+
if (inlineHandlers > 0) {
|
|
327
|
+
findings.push({ severity: 'med', text: `${inlineHandlers} inline event handler(s) in scope` });
|
|
328
|
+
}
|
|
329
|
+
if (unsafeCspHits > 0) {
|
|
330
|
+
findings.push({ severity: 'high', text: `${unsafeCspHits} mention(s) of CSP unsafe-inline / unsafe-eval` });
|
|
331
|
+
}
|
|
332
|
+
// Lighthouse audit (optional peer)
|
|
333
|
+
if (peerInputs && peerInputs.lighthouse !== undefined) {
|
|
334
|
+
// r21-MED: isolate evaluator failures — a malformed lighthouse peer
|
|
335
|
+
// input must not throw out of the grader and crash the review.
|
|
336
|
+
try {
|
|
337
|
+
const lh = evaluateLighthouse(peerInputs.lighthouse);
|
|
338
|
+
if (lh.pass === false) {
|
|
339
|
+
findings.push({ severity: 'med', text: `lighthouse: LCP ${lh.lcpMs}ms / CLS ${lh.clsScore} -- ${lh.reason}` });
|
|
340
|
+
}
|
|
341
|
+
} catch (err) {
|
|
342
|
+
findings.push({ severity: 'med', text: `lighthouse evaluation failed: ${err && err.message ? err.message : String(err)}` });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
let verdict = VERDICT_PASS;
|
|
346
|
+
if (findings.some((f) => f.severity === 'high')) verdict = VERDICT_BLOCK;
|
|
347
|
+
else if (findings.length > 0) verdict = VERDICT_FLAG;
|
|
348
|
+
return { pillar: 'security', verdict, findings, evidence: `${inlineHandlers} inline handlers, ${unsafeCspHits} unsafe CSP hits`, startedAt, finishedAt: Date.now() };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const GRADERS = Object.freeze({
|
|
352
|
+
layout: gradeLayout,
|
|
353
|
+
typography: gradeTypography,
|
|
354
|
+
color: gradeColor,
|
|
355
|
+
spacing: gradeSpacing,
|
|
356
|
+
components: gradeComponents,
|
|
357
|
+
interaction: gradeInteraction,
|
|
358
|
+
security: gradeSecurity,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// Top-level runner
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Run the 7-pillar UI review. All 7 graders fire in parallel via Promise.all
|
|
367
|
+
* (W1.E). Returns the structured result + writes UI-REVIEW.md next to the
|
|
368
|
+
* UI-SPEC path.
|
|
369
|
+
*
|
|
370
|
+
* @param {object} args
|
|
371
|
+
* @param {string} args.uiSpecPath absolute path to UI-SPEC.md
|
|
372
|
+
* @param {string|string[]} args.sourceScope dirs to grade (comma-separated or array)
|
|
373
|
+
* @param {string} [args.projectRoot] default cwd
|
|
374
|
+
* @param {object} [args.peerInputs] { axe, lighthouse, playwright } -- optional pre-computed peer-tool outputs
|
|
375
|
+
* @param {boolean} [args.write] when true, write UI-REVIEW.md (default true)
|
|
376
|
+
* @param {boolean} [args.gcSketches] when true, run sketches-gc as the finalizer (default false)
|
|
377
|
+
* @returns {Promise<{
|
|
378
|
+
* topVerdict: 'PASS'|'FLAG'|'BLOCK',
|
|
379
|
+
* pillarVerdicts: Record<string, string>,
|
|
380
|
+
* verdicts: Array<{pillar, verdict, findings, startedAt, finishedAt}>,
|
|
381
|
+
* reviewPath: string|null,
|
|
382
|
+
* reviewMarkdown: string,
|
|
383
|
+
* parallel: { minStart: number, maxStart: number, minFinish: number, maxFinish: number, parallelism: number }
|
|
384
|
+
* }>}
|
|
385
|
+
*/
|
|
386
|
+
export async function runUiReview({
|
|
387
|
+
uiSpecPath,
|
|
388
|
+
sourceScope,
|
|
389
|
+
projectRoot = process.cwd(),
|
|
390
|
+
peerInputs = {},
|
|
391
|
+
write = true,
|
|
392
|
+
gcSketches = false,
|
|
393
|
+
} = {}) {
|
|
394
|
+
if (typeof uiSpecPath !== 'string' || uiSpecPath.length === 0) {
|
|
395
|
+
throw new TypeError('runUiReview: uiSpecPath is required');
|
|
396
|
+
}
|
|
397
|
+
if (!existsSync(uiSpecPath)) {
|
|
398
|
+
throw new Error(`runUiReview: UI-SPEC not found at ${uiSpecPath}`);
|
|
399
|
+
}
|
|
400
|
+
const scopes = Array.isArray(sourceScope)
|
|
401
|
+
? sourceScope
|
|
402
|
+
: typeof sourceScope === 'string'
|
|
403
|
+
? sourceScope.split(',').map((s) => s.trim()).filter(Boolean)
|
|
404
|
+
: [];
|
|
405
|
+
if (scopes.length === 0) {
|
|
406
|
+
throw new Error('runUiReview: sourceScope is required (comma-separated paths or array)');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const rawSpec = readFileSync(uiSpecPath, 'utf8');
|
|
410
|
+
const spec = parseUISpec(rawSpec);
|
|
411
|
+
spec.__rawText = rawSpec;
|
|
412
|
+
|
|
413
|
+
const files = walkSourceFiles(scopes, projectRoot);
|
|
414
|
+
|
|
415
|
+
// W1.E: 7 graders in parallel via Promise.all. Concurrency witness is a
|
|
416
|
+
// counter (not timestamps): each grader increments `inFlight` on entry,
|
|
417
|
+
// yields the microtask queue once, then runs to completion. With
|
|
418
|
+
// Promise.all dispatch, all 7 graders enter their wrapper in the same
|
|
419
|
+
// tick before any returns -- so `peakConcurrent === PILLARS.length`.
|
|
420
|
+
// A sequential implementation would peak at 1.
|
|
421
|
+
//
|
|
422
|
+
// What this witness proves: Promise.all DISPATCH is concurrent — the 7
|
|
423
|
+
// grader wrappers all run their entry block in the same event-loop tick
|
|
424
|
+
// before any can exit. It does NOT prove that the (currently synchronous)
|
|
425
|
+
// grader BODIES execute interleaved on the event loop — sync work
|
|
426
|
+
// serializes by definition. If a grader gains a real async operation
|
|
427
|
+
// (e.g. a Lighthouse call), the witness already accommodates it: the
|
|
428
|
+
// yield ensures all peers register before any awaits. This is the
|
|
429
|
+
// semantic guarantee that matters; the lib design intentionally keeps
|
|
430
|
+
// grader bodies cheap + sync so the runner stays fast.
|
|
431
|
+
// (Replaces the earlier Date.now() ms-precision comparison which was
|
|
432
|
+
// flaky on fast sync work.)
|
|
433
|
+
const graderArgs = { spec, sourceScope: scopes, files, projectRoot, peerInputs };
|
|
434
|
+
const beforeAll = Date.now();
|
|
435
|
+
let _inFlight = 0;
|
|
436
|
+
let _peakConcurrent = 0;
|
|
437
|
+
const verdicts = await Promise.all(
|
|
438
|
+
PILLARS.map((pillar) => Promise.resolve().then(async () => {
|
|
439
|
+
_inFlight += 1;
|
|
440
|
+
if (_inFlight > _peakConcurrent) _peakConcurrent = _inFlight;
|
|
441
|
+
// Yield so all other graders also reach this point before any finishes.
|
|
442
|
+
await Promise.resolve();
|
|
443
|
+
try { return GRADERS[pillar](graderArgs); }
|
|
444
|
+
finally { _inFlight -= 1; }
|
|
445
|
+
})),
|
|
446
|
+
);
|
|
447
|
+
const afterAll = Date.now();
|
|
448
|
+
|
|
449
|
+
// Parallelism stats — used by tests + observability.
|
|
450
|
+
const startedAtList = verdicts.map((v) => v.startedAt).sort((a, b) => a - b);
|
|
451
|
+
const finishedAtList = verdicts.map((v) => v.finishedAt).sort((a, b) => a - b);
|
|
452
|
+
const parallel = {
|
|
453
|
+
minStart: startedAtList[0],
|
|
454
|
+
maxStart: startedAtList[startedAtList.length - 1],
|
|
455
|
+
minFinish: finishedAtList[0],
|
|
456
|
+
maxFinish: finishedAtList[finishedAtList.length - 1],
|
|
457
|
+
// Concurrency witness: how many graders were simultaneously inside the
|
|
458
|
+
// dispatcher wrapper at the peak. Equals PILLARS.length when Promise.all
|
|
459
|
+
// dispatch is parallel; would be 1 if implementation went sequential.
|
|
460
|
+
peakConcurrent: _peakConcurrent,
|
|
461
|
+
// True iff the witness == PILLARS.length (all 7 entered before any
|
|
462
|
+
// exited). Robust to wall-clock resolution since it's a tick-level event
|
|
463
|
+
// count, not a timestamp comparison.
|
|
464
|
+
parallelism: _peakConcurrent === PILLARS.length,
|
|
465
|
+
wallMs: afterAll - beforeAll,
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const pillarVerdicts = {};
|
|
469
|
+
for (const v of verdicts) pillarVerdicts[v.pillar] = v.verdict;
|
|
470
|
+
|
|
471
|
+
const topVerdict = computeTopVerdict(verdicts);
|
|
472
|
+
|
|
473
|
+
const reviewMarkdown = renderReview({
|
|
474
|
+
uiSpecPath,
|
|
475
|
+
sourceScope: scopes,
|
|
476
|
+
verdicts,
|
|
477
|
+
topVerdict,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
let reviewPath = null;
|
|
481
|
+
if (write) {
|
|
482
|
+
reviewPath = join(dirname(uiSpecPath), 'UI-REVIEW.md');
|
|
483
|
+
try { mkdirSync(dirname(reviewPath), { recursive: true }); } catch {}
|
|
484
|
+
writeFileSync(reviewPath, reviewMarkdown, 'utf8');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (gcSketches) {
|
|
488
|
+
try { runSketchesGc({ root: join(projectRoot, '.planning', 'sketches') }); } catch {}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return { topVerdict, pillarVerdicts, verdicts, reviewPath, reviewMarkdown, parallel };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function computeTopVerdict(verdicts) {
|
|
495
|
+
// BLOCK > FLAG > PASS; spec-section-missing ranks as BLOCK (auditor agent
|
|
496
|
+
// rule: spec-missing is treated as blocking until the spec is updated).
|
|
497
|
+
const ranks = { PASS: 0, FLAG: 1, BLOCK: 2 };
|
|
498
|
+
let top = 'PASS';
|
|
499
|
+
for (const v of verdicts) {
|
|
500
|
+
const norm = v.verdict === VERDICT_MISSING ? 'BLOCK' : v.verdict;
|
|
501
|
+
if ((ranks[norm] ?? 0) > (ranks[top] ?? 0)) top = norm;
|
|
502
|
+
}
|
|
503
|
+
return top;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function renderReview({ uiSpecPath, sourceScope, verdicts, topVerdict }) {
|
|
507
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
508
|
+
const scopeStr = Array.isArray(sourceScope) ? sourceScope.join(',') : String(sourceScope);
|
|
509
|
+
const lines = [
|
|
510
|
+
`# UI-REVIEW`,
|
|
511
|
+
`**Audited:** ${date} **Auditor:** ijfw-ui-review-runner`,
|
|
512
|
+
`**Spec:** ${uiSpecPath} **Source scope:** ${scopeStr}`,
|
|
513
|
+
`**Top-level verdict:** ${topVerdict}`,
|
|
514
|
+
'',
|
|
515
|
+
'## Per-pillar verdicts',
|
|
516
|
+
'',
|
|
517
|
+
];
|
|
518
|
+
for (const v of verdicts) {
|
|
519
|
+
const title = PILLAR_TITLES[v.pillar] || v.pillar;
|
|
520
|
+
lines.push(`### ${title} — ${v.verdict}`);
|
|
521
|
+
if (v.reason) lines.push(`- **Reason:** ${v.reason}`);
|
|
522
|
+
if (v.evidence) lines.push(`- **Evidence:** ${v.evidence}`);
|
|
523
|
+
if (v.findings && v.findings.length > 0) {
|
|
524
|
+
for (const f of v.findings) {
|
|
525
|
+
const where = f.file ? `\`${f.file}${f.line ? ':' + f.line : ''}\`` : '';
|
|
526
|
+
lines.push(`- **Finding (${f.severity || 'info'}):** ${f.text}${where ? ` ${where}` : ''}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
lines.push('');
|
|
530
|
+
}
|
|
531
|
+
// Summary
|
|
532
|
+
const blocks = verdicts.filter((v) => v.verdict === VERDICT_BLOCK || v.verdict === VERDICT_MISSING).map((v) => v.pillar);
|
|
533
|
+
const flags = verdicts.filter((v) => v.verdict === VERDICT_FLAG).map((v) => v.pillar);
|
|
534
|
+
const passes = verdicts.filter((v) => v.verdict === VERDICT_PASS).map((v) => v.pillar);
|
|
535
|
+
const totalFindings = verdicts.reduce((n, v) => n + (v.findings ? v.findings.length : 0), 0);
|
|
536
|
+
const blockFindings = verdicts.reduce((n, v) => n + (v.findings ? v.findings.filter((f) => f.severity === 'high').length : 0), 0);
|
|
537
|
+
lines.push('## Summary');
|
|
538
|
+
lines.push('');
|
|
539
|
+
lines.push(`- **Top-level:** ${topVerdict}`);
|
|
540
|
+
lines.push(`- **Pillars at BLOCK:** ${blocks.length > 0 ? blocks.join(', ') : 'none'}`);
|
|
541
|
+
lines.push(`- **Pillars at FLAG:** ${flags.length > 0 ? flags.join(', ') : 'none'}`);
|
|
542
|
+
lines.push(`- **Pillars at PASS:** ${passes.length > 0 ? passes.join(', ') : 'none'}`);
|
|
543
|
+
lines.push(`- **Total findings:** ${totalFindings} (${blockFindings} BLOCK / ${totalFindings - blockFindings} FLAG)`);
|
|
544
|
+
lines.push('');
|
|
545
|
+
return lines.join('\n');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Constants exported for tests + tooling.
|
|
549
|
+
export const RUNNER_DEFAULTS = Object.freeze({
|
|
550
|
+
pillars: PILLARS,
|
|
551
|
+
pillarTitles: PILLAR_TITLES,
|
|
552
|
+
verdicts: Object.freeze({ PASS: VERDICT_PASS, FLAG: VERDICT_FLAG, BLOCK: VERDICT_BLOCK, MISSING: VERDICT_MISSING }),
|
|
553
|
+
lighthouseThresholds: LIGHTHOUSE_THRESHOLDS,
|
|
554
|
+
});
|