Compare commits

..
Author SHA1 Message Date
jarvis 8a405b141d docs(ri-050): forge fail-closed docs + TASKS status catch-up (#1275)
ci/woodpecker/pr/ci Pipeline was successful
Rebased onto next post-#1278. README + skills/mosaic-forge/SKILL.md
document the FORGE_NO_EXECUTOR fail-closed rule + --simulate (fast-follow
to RI-2-001, now merged). TASKS.md rows updated to measured state:
RI-0-001/RI-1-001/RI-1-002/RI-2-001 done (merged with reviews+CI evidence);
RI-2-002/RI-3-001/RI-4-001/RI-5-001 in-progress (PRs open, approved,
merge-queued); RI-3-002/RI-V-001 not-started.
2026-08-18 00:55:58 -05:00
58 changed files with 718 additions and 7943 deletions
@@ -1,110 +0,0 @@
'use client';
import type { ReactElement } from 'react';
import { formatAge, type FreshnessLabel } from '@/lib/freshness/model';
/**
* Rendering rules for non-current freshness states (RI-5-001).
*
* - `unavailable` renders an explicit failure panel — never an empty
* healthy collection.
* - `stale` may render last-known data, but only under a visible label
* carrying source identity, snapshot version, and age.
* - `partial` renders the verified parts plus an explicit list of what is
* missing.
*/
interface RetryableNoticeProps {
readonly onRetry?: () => void;
readonly retryLabel?: string;
}
function RetryButton({ onRetry, retryLabel }: RetryableNoticeProps): ReactElement | null {
if (!onRetry) return null;
return (
<button
type="button"
onClick={onRetry}
className="mt-2 rounded-lg border border-surface-border px-3 py-1.5 text-xs transition-colors hover:border-gray-500"
>
{retryLabel ?? 'Retry'}
</button>
);
}
export interface UnavailableDataNoticeProps extends RetryableNoticeProps {
/** What is unavailable, e.g. "Tasks". */
readonly title: string;
/** Optional underlying failure detail (network message, invalidation reason). */
readonly detail?: string | null;
}
/** Explicit `unavailable` state. Never renders as an empty healthy collection. */
export function UnavailableDataNotice({
title,
detail,
onRetry,
retryLabel,
}: UnavailableDataNoticeProps): ReactElement {
return (
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
<p className="font-medium text-text-primary">{title} are unavailable</p>
<p className="mt-1 text-text-muted">
This is not an empty result the data could not be verified from the gateway.
{detail ? ` ${detail}` : ''}
</p>
<RetryButton onRetry={onRetry} retryLabel={retryLabel} />
</div>
);
}
export interface StaleDataNoticeProps extends RetryableNoticeProps {
/** Provenance of the last-known snapshot being displayed. */
readonly label: FreshnessLabel;
}
/**
* Situational-awareness banner for `stale` data: last-known data may render,
* but visibly labeled with source identity, snapshot version, and age.
*/
export function StaleDataNotice({
label,
onRetry,
retryLabel,
}: StaleDataNoticeProps): ReactElement {
return (
<div role="status" className="rounded-lg border border-warning/40 px-4 py-3 text-sm">
<p className="font-medium text-warning">Showing last-known data it may be out of date</p>
<p className="mt-1 text-xs text-text-muted">
Source {label.source} · snapshot v{label.version} · fetched{' '}
{formatAge(label.fetchedAt, Date.now())}. Verdicts derived from this data are unknown and
changes are disabled until it is revalidated.
</p>
<RetryButton onRetry={onRetry} retryLabel={retryLabel ?? 'Revalidate'} />
</div>
);
}
export interface PartialDataNoticeProps extends RetryableNoticeProps {
/** Display names of the sections whose collections are unavailable. */
readonly missing: readonly string[];
}
/** `partial` surface banner: verified parts render, missing parts are explicit. */
export function PartialDataNotice({
missing,
onRetry,
retryLabel,
}: PartialDataNoticeProps): ReactElement {
return (
<div role="status" className="rounded-lg border border-warning/40 px-4 py-3 text-sm">
<p className="font-medium text-warning">Some data could not be loaded</p>
<p className="mt-1 text-xs text-text-muted">
{missing.join(', ')} {missing.length === 1 ? 'is' : 'are'} unavailable sections below show
an explicit unavailable state instead of an empty list. Derived verdicts remain unknown
until every collection is revalidated.
</p>
<RetryButton onRetry={onRetry} retryLabel={retryLabel ?? 'Revalidate'} />
</div>
);
}
-324
View File
@@ -1,324 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { Task } from '@/lib/types';
import {
acceptSnapshot,
assertMutable,
canMutate,
combineFreshness,
computeDigest,
computeFreshness,
DEFAULT_FRESHNESS_POLICY,
formatAge,
type FreshSnapshot,
invalidationReasonLabels,
StaleMutationError,
UNKNOWN_VERDICT,
verdictValue,
} from './model';
import { validateProjectCollection, validateTaskCollection } from './validators';
const NOW = 1_800_000_000_000;
const policy = { ...DEFAULT_FRESHNESS_POLICY, staleAfterMs: 60_000 };
const taskPayload: Task[] = [
{
id: 'task-1',
title: 'T1',
description: null,
status: 'not-started',
priority: 'high',
projectId: 'project-1',
missionId: null,
assignee: null,
tags: null,
dueDate: null,
metadata: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
];
function acceptedTaskSnapshot(
overrides: Partial<FreshSnapshot<typeof taskPayload>> = {},
): FreshSnapshot<typeof taskPayload> {
const result = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous: null,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
if (result.outcome !== 'accepted') {
throw new Error(`fixture setup failed: ${result.reason}`);
}
return { ...result.snapshot, ...overrides };
}
describe('computeFreshness', () => {
it('treats a missing snapshot as unavailable, never as an empty healthy collection', () => {
expect(computeFreshness({ snapshot: null, policy, now: NOW })).toBe('unavailable');
});
it('returns current for a fresh verified snapshot regardless of data emptiness', () => {
const empty = acceptSnapshot({
value: [],
validate: validateTaskCollection,
previous: null,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
if (empty.outcome !== 'accepted') throw new Error('expected acceptance');
expect(computeFreshness({ snapshot: empty.snapshot, policy, now: NOW })).toBe('current');
});
it('degrades to stale once the snapshot ages past staleAfterMs', () => {
const snapshot = acceptedTaskSnapshot();
expect(computeFreshness({ snapshot, policy, now: NOW + 60_001 })).toBe('stale');
expect(computeFreshness({ snapshot, policy, now: NOW + 59_999 })).toBe('current');
});
it('degrades to stale when the latest revalidation failed', () => {
const snapshot = acceptedTaskSnapshot();
expect(computeFreshness({ snapshot, policy, now: NOW, degraded: true })).toBe('stale');
});
});
describe('mutation guard', () => {
it('permits mutations only on current data', () => {
expect(canMutate('current')).toBe(true);
for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) {
expect(canMutate(state)).toBe(false);
}
});
it('refuses mutations on non-current data via assertMutable', () => {
expect(() => assertMutable('current')).not.toThrow();
for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) {
let thrown: unknown;
try {
assertMutable(state);
} catch (caught) {
thrown = caught;
}
expect(thrown).toBeInstanceOf(StaleMutationError);
expect(thrown).toBeInstanceOf(Error);
if (thrown instanceof StaleMutationError) {
expect(thrown.name).toBe('StaleMutationError');
expect(thrown.freshness).toBe(state);
expect(thrown.message).toContain(state);
expect(thrown.message).toContain('revalidat');
}
}
});
});
describe('acceptSnapshot', () => {
it('accepts a valid payload with provenance', () => {
const result = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous: null,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
expect(result.outcome).toBe('accepted');
if (result.outcome !== 'accepted') return;
expect(result.snapshot.source).toBe('gateway:/api/tasks');
expect(result.snapshot.version).toBe(1);
expect(result.snapshot.fetchedAt).toBe(NOW);
expect(result.snapshot.data).toEqual(taskPayload);
});
it('invalidates a schema-mismatched payload instead of rendering it', () => {
const result = acceptSnapshot({
value: { not: 'an array' },
validate: validateTaskCollection,
previous: acceptedTaskSnapshot(),
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
expect(result).toEqual({ outcome: 'invalidated', reason: 'schema-mismatch' });
expect(invalidationReasonLabels['schema-mismatch']).toContain('schema');
});
it('invalidates cross-workspace payloads', () => {
const userOne = acceptSnapshot({
value: [
{
id: 'p1',
name: 'P1',
description: null,
status: 'active',
userId: 'user-1',
metadata: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
],
validate: validateProjectCollection,
previous: null,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
if (userOne.outcome !== 'accepted') throw new Error('expected acceptance');
const switched = acceptSnapshot({
value: [
{
id: 'p9',
name: 'P9',
description: null,
status: 'active',
userId: 'user-2',
metadata: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
],
validate: validateProjectCollection,
previous: userOne.snapshot,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
expect(switched).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' });
});
it('keeps the previous workspace for collections with no intrinsic identity', () => {
const userOne = acceptSnapshot({
value: [
{
id: 'p1',
name: 'P1',
description: null,
status: 'active',
userId: 'user-1',
metadata: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
],
validate: validateProjectCollection,
previous: null,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
if (userOne.outcome !== 'accepted') throw new Error('expected acceptance');
// Empty list after the user deleted every project: no identity to check,
// so the verified scope is retained and the empty state stays healthy.
const emptied = acceptSnapshot({
value: [],
validate: validateProjectCollection,
previous: userOne.snapshot,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
expect(emptied.outcome).toBe('accepted');
if (emptied.outcome === 'accepted') {
expect(emptied.snapshot.data).toEqual([]);
expect(emptied.snapshot.workspace).toBe('user-1');
}
});
it('invalidates version regressions', () => {
const previous = acceptedTaskSnapshot({ version: 7 });
const regressed = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous,
policy,
source: 'gateway:/api/tasks',
now: NOW,
incomingVersion: 3,
});
expect(regressed).toEqual({ outcome: 'invalidated', reason: 'version-regression' });
const newerSchema = acceptedTaskSnapshot({ schemaVersion: 4 });
const downgradedClient = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous: newerSchema,
policy: { ...policy, schemaVersion: 2 },
source: 'gateway:/api/tasks',
now: NOW,
});
expect(downgradedClient).toEqual({ outcome: 'invalidated', reason: 'version-regression' });
});
it('increments the version monotonically across accepted snapshots', () => {
const first = acceptedTaskSnapshot();
const second = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous: first,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
expect(second.outcome).toBe('accepted');
if (second.outcome === 'accepted') {
expect(second.snapshot.version).toBe(first.version + 1);
}
});
});
describe('combineFreshness', () => {
it('gates the surface on the primary collection', () => {
expect(combineFreshness('unavailable', ['current'])).toBe('unavailable');
expect(combineFreshness('unknown', ['current'])).toBe('unknown');
expect(combineFreshness('current', [])).toBe('current');
});
it('degrades to partial when a secondary is unavailable', () => {
expect(combineFreshness('current', ['current', 'unavailable'])).toBe('partial');
});
it('degrades to unknown while a secondary is still loading', () => {
expect(combineFreshness('current', ['unknown'])).toBe('unknown');
});
it('degrades to stale when any collection is stale', () => {
expect(combineFreshness('current', ['stale'])).toBe('stale');
expect(combineFreshness('stale', ['current'])).toBe('stale');
});
it('propagates partial secondaries', () => {
expect(combineFreshness('current', ['partial'])).toBe('partial');
});
});
describe('computeDigest', () => {
it('is stable across key order and changes with data', () => {
const a = computeDigest({ x: 1, y: [1, 2] });
const b = computeDigest({ y: [1, 2], x: 1 });
expect(a).toBe(b);
expect(computeDigest({ x: 1, y: [1, 3] })).not.toBe(a);
});
});
describe('verdictValue', () => {
it('returns the value only for verified inputs', () => {
expect(verdictValue(true, '5')).toBe('5');
expect(verdictValue(false, '5')).toBe(UNKNOWN_VERDICT);
expect(verdictValue(false, '5')).not.toBe('5');
});
});
describe('formatAge', () => {
it('labels age in human terms', () => {
expect(formatAge(NOW, NOW)).toBe('just now');
expect(formatAge(NOW, NOW + 15_000)).toBe('under a minute ago');
expect(formatAge(NOW, NOW + 120_000)).toBe('2m ago');
expect(formatAge(NOW, NOW + 3 * 3_600_000)).toBe('3h ago');
expect(formatAge(NOW, NOW + 2 * 86_400_000)).toBe('2d ago');
});
});
-261
View File
@@ -1,261 +0,0 @@
/**
* Typed freshness model for gateway-fetched collections (RI-5-001).
*
* A failed or stale fetch must never be indistinguishable from an empty
* healthy collection. Every fetched surface carries an explicit freshness
* state, a verified snapshot identity (source, workspace, version, age), and
* a mutation guard that refuses state-changing operations unless the data is
* verified current.
*/
/** Freshness states for fetched data. Never inferred from emptiness. */
export type FreshnessState = 'current' | 'stale' | 'partial' | 'unknown' | 'unavailable';
/**
* Reasons a snapshot is invalidated. An invalidated snapshot is treated as
* unavailable and is never rendered as current.
*/
export type InvalidationReason =
| 'cache-corruption'
| 'cross-workspace'
| 'schema-mismatch'
| 'version-regression';
/** Human-readable labels for invalidation reasons (UI + error messages). */
export const invalidationReasonLabels: Record<InvalidationReason, string> = {
'cache-corruption': 'cached snapshot failed integrity checks',
'cross-workspace': 'data belongs to a different workspace',
'schema-mismatch': 'response did not match the expected schema',
'version-regression': 'snapshot version regressed below the accepted version',
};
/** A verified snapshot of fetched data with full provenance. */
export interface FreshSnapshot<T> {
readonly data: T;
/** Source identity of the fetch, e.g. `gateway:/api/tasks`. */
readonly source: string;
/** Workspace scope the data belongs to. */
readonly workspace: string;
/** Monotonic snapshot sequence number for this surface. */
readonly version: number;
/** Schema version of the validator that accepted this snapshot. */
readonly schemaVersion: number;
/** Epoch ms at which the data was verified. */
readonly fetchedAt: number;
/** Integrity digest of `data`, used to detect cache corruption. */
readonly digest: string;
}
/** Provenance label rendered next to last-known data. */
export interface FreshnessLabel {
readonly source: string;
readonly version: number;
readonly fetchedAt: number;
}
/** Policy governing freshness for a surface. */
export interface FreshnessPolicy {
/** Active workspace scope. Snapshots from other scopes are invalidated. */
readonly workspace: string;
/** Schema version of the current validator. */
readonly schemaVersion: number;
/** Age after which a verified snapshot degrades from current to stale. */
readonly staleAfterMs: number;
}
export const DEFAULT_FRESHNESS_POLICY: FreshnessPolicy = {
workspace: 'default',
schemaVersion: 1,
staleAfterMs: 60_000,
};
/** Payload returned by a successful schema validation. */
export interface FreshPayload<T> {
readonly data: T;
/**
* Workspace identity extracted from the payload itself when the collection
* carries one (e.g. a uniform `userId` on projects). `null` when the
* collection has no intrinsic workspace identity.
*/
readonly workspace: string | null;
}
/** Error thrown when a mutation is attempted on non-current data. */
export class StaleMutationError extends Error {
readonly freshness: FreshnessState;
constructor(freshness: FreshnessState) {
super(`Refused mutation on ${freshness} data: revalidation is required before mutating.`);
this.name = 'StaleMutationError';
this.freshness = freshness;
}
}
/** Stable JSON digest used for snapshot integrity checks. */
export function computeDigest(value: unknown): string {
// FNV-1a 32-bit over the stable JSON serialization. This is an integrity
// check against corruption, not a cryptographic guarantee.
let hash = 0x811c9dc5;
for (const byte of stableStringify(value)) {
hash ^= byte.charCodeAt(0);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}
function stableStringify(value: unknown): string {
return serialize(value);
}
function serialize(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
if (Array.isArray(value)) return `[${value.map(serialize).join(',')}]`;
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, item]) => item !== undefined)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([key, item]) => `${JSON.stringify(key)}:${serialize(item)}`);
return `{${entries.join(',')}}`;
}
export type AcceptSnapshotResult<T> =
| { readonly outcome: 'accepted'; readonly snapshot: FreshSnapshot<T> }
| { readonly outcome: 'invalidated'; readonly reason: InvalidationReason };
export interface AcceptSnapshotOptions<T> {
/** Raw fetched value (untrusted JSON). */
readonly value: unknown;
/** Schema validator; returns `null` when the value does not match. */
readonly validate: (value: unknown) => FreshPayload<T> | null;
/** Previously accepted snapshot for this surface, if any. */
readonly previous: FreshSnapshot<T> | null;
readonly policy: FreshnessPolicy;
readonly source: string;
/**
* Version carried by the incoming payload when the transport exposes one.
* Must not regress below the accepted snapshot's version.
*/
readonly incomingVersion?: number;
readonly now: number;
}
/**
* Validate and accept a fetched value as a snapshot, or invalidate it.
*
* Invalidation rules (each treated as unavailable, never rendered current):
* - schema mismatch: the payload fails validation
* - cross-workspace: the payload's workspace differs from the verified one
* - version regression: payload/schema version is below the accepted one
*/
export function acceptSnapshot<T>(options: AcceptSnapshotOptions<T>): AcceptSnapshotResult<T> {
const payload = options.validate(options.value);
if (payload === null) {
return { outcome: 'invalidated', reason: 'schema-mismatch' };
}
// Workspace identity: the payload's own scope wins; a collection with no
// intrinsic identity (e.g. an empty list after every project was deleted)
// keeps the previously verified scope rather than resetting to the policy
// default, so a legitimately empty response is not mistaken for a scope
// change.
const workspace = payload.workspace ?? options.previous?.workspace ?? options.policy.workspace;
if (options.previous !== null && options.previous.workspace !== workspace) {
return { outcome: 'invalidated', reason: 'cross-workspace' };
}
if (options.previous !== null && options.policy.schemaVersion < options.previous.schemaVersion) {
return { outcome: 'invalidated', reason: 'version-regression' };
}
if (
options.incomingVersion !== undefined &&
options.previous !== null &&
options.incomingVersion < options.previous.version
) {
return { outcome: 'invalidated', reason: 'version-regression' };
}
const snapshot: FreshSnapshot<T> = {
data: payload.data,
source: options.source,
workspace,
version: options.incomingVersion ?? (options.previous?.version ?? 0) + 1,
schemaVersion: options.policy.schemaVersion,
fetchedAt: options.now,
digest: computeDigest(payload.data),
};
return { outcome: 'accepted', snapshot };
}
export interface ComputeFreshnessOptions {
readonly snapshot: FreshSnapshot<unknown> | null;
readonly policy: FreshnessPolicy;
readonly now: number;
/**
* True when the snapshot cannot be trusted as current regardless of age:
* the latest revalidation failed, or the snapshot was restored from cache
* and has not been verified by a fetch in this session.
*/
readonly degraded?: boolean;
}
/**
* Compute the freshness state of a snapshot. A missing snapshot is
* `unavailable` (never "empty and healthy"); a degraded or aged snapshot is
* `stale` (situational awareness only).
*/
export function computeFreshness(options: ComputeFreshnessOptions): FreshnessState {
const { snapshot, policy, now, degraded = false } = options;
if (snapshot === null) return 'unavailable';
if (degraded) return 'stale';
if (now - snapshot.fetchedAt > policy.staleAfterMs) return 'stale';
return 'current';
}
/** Only verified-current data may back a state-changing action. */
export function canMutate(state: FreshnessState): boolean {
return state === 'current';
}
/** Defense in depth: reject the mutation call itself on non-current data. */
export function assertMutable(state: FreshnessState): void {
if (!canMutate(state)) {
throw new StaleMutationError(state);
}
}
/**
* Combine freshness across a multi-collection surface (primary + secondaries).
* The primary collection gates the surface: unknown while it loads,
* unavailable when it fails. Missing secondaries degrade the surface to
* `partial`; aged collections degrade it to `stale`.
*/
export function combineFreshness(
primary: FreshnessState,
secondaries: readonly FreshnessState[],
): FreshnessState {
if (primary === 'unavailable') return 'unavailable';
if (primary === 'unknown') return 'unknown';
if (secondaries.includes('unavailable')) return 'partial';
if (secondaries.includes('unknown')) return 'unknown';
if (secondaries.includes('stale') || primary === 'stale') return 'stale';
if (secondaries.includes('partial')) return 'partial';
return 'current';
}
/** Render-safe age label for snapshot provenance. */
export function formatAge(fetchedAt: number, now: number): string {
const ageMs = Math.max(0, now - fetchedAt);
if (ageMs < 10_000) return 'just now';
const minutes = Math.floor(ageMs / 60_000);
if (minutes < 1) return 'under a minute ago';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
/** Derived verdict placeholder for non-current inputs — never a green value. */
export const UNKNOWN_VERDICT = '?';
export function verdictValue(verified: boolean, value: string): string {
return verified ? value : UNKNOWN_VERDICT;
}
@@ -1,197 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { acceptSnapshot, DEFAULT_FRESHNESS_POLICY } from './model';
import { clearSnapshotCache, readSnapshotCache, writeSnapshotCache } from './snapshot-cache';
import { validateProjectCollection, validateTaskCollection } from './validators';
import { projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures';
import type { Project, Task } from '@/lib/types';
const KEY = 'test:tasks';
const NOW = 1_800_000_000_000;
const policy = { ...DEFAULT_FRESHNESS_POLICY, staleAfterMs: 60_000 };
function storedTaskSnapshot() {
const result = acceptSnapshot({
value: taskFixtures,
validate: validateTaskCollection,
previous: null,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
if (result.outcome !== 'accepted') throw new Error('fixture setup failed');
return result.snapshot;
}
function storedProjectSnapshot() {
const result = acceptSnapshot({
value: projectFixtures,
validate: validateProjectCollection,
previous: null,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
if (result.outcome !== 'accepted') throw new Error('fixture setup failed');
return result.snapshot;
}
function readTasks() {
return readSnapshotCache({
key: KEY,
workspace: policy.workspace,
policy,
validate: validateTaskCollection,
});
}
/** Write an arbitrary value directly at the raw cache slot. */
function writeRaw(key: string, value: unknown): void {
sessionStorage.setItem(`mosaic:freshness:v1:${key}`, JSON.stringify(value));
}
/** Parse and re-write the stored entry (for tampering with internals). */
function tamperStored<T>(key: string, mutate: (stored: T) => void): void {
const parsed = JSON.parse(sessionStorage.getItem(`mosaic:freshness:v1:${key}`) ?? '{}') as T;
mutate(parsed);
writeRaw(key, parsed);
}
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
sessionStorage.clear();
});
describe('readSnapshotCache', () => {
it('misses when nothing is stored', () => {
expect(readTasks()).toEqual({ outcome: 'miss' });
});
it('hits for a well-formed entry and preserves provenance', () => {
const snapshot = storedTaskSnapshot();
writeSnapshotCache(KEY, snapshot);
const result = readTasks();
expect(result.outcome).toBe('hit');
if (result.outcome === 'hit') {
expect(result.snapshot.data).toEqual(taskFixtures);
expect(result.snapshot.source).toBe('gateway:/api/tasks');
expect(result.snapshot.version).toBe(snapshot.version);
expect(result.snapshot.fetchedAt).toBe(snapshot.fetchedAt);
expect(result.snapshot.workspace).toBe(snapshot.workspace);
}
});
it('invalidates unparsable entries as cache corruption', () => {
sessionStorage.setItem(`mosaic:freshness:v1:${KEY}`, '{not json');
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' });
});
it('invalidates structurally wrong entries as cache corruption', () => {
const malformed: unknown[] = [
'nested but not a snapshot',
{ data: taskFixtures }, // missing provenance fields
{
data: taskFixtures,
source: 1,
workspace: 'w',
version: 1,
schemaVersion: 1,
fetchedAt: 1,
digest: 'x',
},
null,
17,
];
for (const entry of malformed) {
writeRaw(KEY, entry);
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' });
}
});
it('invalidates digest mismatches as cache corruption (tampered data)', () => {
writeSnapshotCache(KEY, storedTaskSnapshot());
tamperStored<{ data: Task[] }>(KEY, (stored) => {
stored.data = [...stored.data, { ...stored.data[0]!, id: 'injected-task' }];
});
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' });
});
it('invalidates entries scoped to another workspace', () => {
const snapshot = storedTaskSnapshot();
writeSnapshotCache(KEY, { ...snapshot, workspace: 'someone-else' });
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' });
});
it('invalidates entries written by a newer schema as a version regression', () => {
const snapshot = storedTaskSnapshot();
writeSnapshotCache(KEY, { ...snapshot, schemaVersion: policy.schemaVersion + 1 });
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'version-regression' });
});
it('invalidates entries whose data no longer validates (schema mismatch)', () => {
writeSnapshotCache(KEY, storedTaskSnapshot());
tamperStored<{ data: unknown }>(KEY, (stored) => {
stored.data = { malformed: true };
});
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'schema-mismatch' });
});
it('never reports a corrupted raw entry as a hit (negative control)', () => {
for (const raw of ['{oops', 'null', '"string"', '[]', '12']) {
sessionStorage.setItem(`mosaic:freshness:v1:${KEY}`, raw);
const result = readTasks();
expect(result.outcome).not.toBe('hit');
expect(result.outcome).toBe('invalidated');
}
});
it('scopes project collections by their workspace identity', () => {
const snapshot = storedProjectSnapshot();
writeSnapshotCache('test:projects', snapshot);
const sameScope = readSnapshotCache({
key: 'test:projects',
workspace: 'user-1',
policy,
validate: validateProjectCollection,
});
expect(sameScope.outcome).toBe('hit');
const foreignScope = readSnapshotCache({
key: 'test:projects',
workspace: 'user-2',
policy,
validate: validateProjectCollection,
});
expect(foreignScope).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' });
});
});
describe('writeSnapshotCache round-trip', () => {
it('round-trips an accepted project snapshot', () => {
const snapshot = storedProjectSnapshot();
writeSnapshotCache('test:projects', snapshot);
const result = readSnapshotCache({
key: 'test:projects',
workspace: snapshot.workspace,
policy,
validate: validateProjectCollection,
});
expect(result.outcome).toBe('hit');
if (result.outcome === 'hit') {
expect(result.snapshot.data).toEqual(projectFixtures as Project[]);
}
});
});
describe('clearSnapshotCache', () => {
it('drops the entry so the next read misses', () => {
writeSnapshotCache(KEY, storedTaskSnapshot());
expect(readTasks().outcome).toBe('hit');
clearSnapshotCache(KEY);
expect(readTasks()).toEqual({ outcome: 'miss' });
});
});
@@ -1,154 +0,0 @@
import {
computeDigest,
type FreshPayload,
type FreshSnapshot,
type FreshnessPolicy,
type InvalidationReason,
} from './model';
/**
* Session-scoped last-known snapshot cache (RI-5-001).
*
* Restored snapshots are situational awareness only: they surface as `stale`
* until a fetch re-verifies them. A cache entry that is corrupted, belongs to
* another workspace, was written by a newer schema, or no longer validates is
* invalidated (treated as unavailable, never rendered as current).
*/
const CACHE_PREFIX = 'mosaic:freshness:v1';
interface StoredSnapshot {
data: unknown;
source: string;
workspace: string;
version: number;
schemaVersion: number;
fetchedAt: number;
digest: string;
}
export type SnapshotCacheRead<T> =
| { readonly outcome: 'hit'; readonly snapshot: FreshSnapshot<T> }
| { readonly outcome: 'miss' }
| { readonly outcome: 'invalidated'; readonly reason: InvalidationReason };
export interface ReadSnapshotCacheOptions<T> {
readonly key: string;
readonly workspace: string;
readonly policy: FreshnessPolicy;
readonly validate: (value: unknown) => FreshPayload<T> | null;
}
function cacheKey(key: string): string {
return `${CACHE_PREFIX}:${key}`;
}
function isStoredSnapshot(value: unknown): value is StoredSnapshot {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate['data'] === 'object' &&
candidate['data'] !== null &&
typeof candidate['source'] === 'string' &&
typeof candidate['workspace'] === 'string' &&
typeof candidate['version'] === 'number' &&
typeof candidate['schemaVersion'] === 'number' &&
typeof candidate['fetchedAt'] === 'number' &&
typeof candidate['digest'] === 'string'
);
}
function getStorage(): Storage | null {
try {
return globalThis.sessionStorage ?? null;
} catch {
return null;
}
}
/**
* Restore a cached snapshot under the active workspace scope. Every failure
* mode maps to an explicit invalidation reason or a miss — never to data
* that renders as current.
*/
export function readSnapshotCache<T>(options: ReadSnapshotCacheOptions<T>): SnapshotCacheRead<T> {
const storage = getStorage();
if (storage === null) return { outcome: 'miss' };
let raw: string | null;
try {
raw = storage.getItem(cacheKey(options.key));
} catch {
return { outcome: 'miss' };
}
if (raw === null) return { outcome: 'miss' };
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { outcome: 'invalidated', reason: 'cache-corruption' };
}
if (!isStoredSnapshot(parsed)) {
return { outcome: 'invalidated', reason: 'cache-corruption' };
}
if (parsed.workspace !== options.workspace) {
return { outcome: 'invalidated', reason: 'cross-workspace' };
}
if (parsed.schemaVersion > options.policy.schemaVersion) {
// Written by a newer build than the running client: version regression.
return { outcome: 'invalidated', reason: 'version-regression' };
}
const payload = options.validate(parsed.data);
if (payload === null) {
return { outcome: 'invalidated', reason: 'schema-mismatch' };
}
if (computeDigest(payload.data) !== parsed.digest) {
return { outcome: 'invalidated', reason: 'cache-corruption' };
}
return {
outcome: 'hit',
snapshot: {
data: payload.data,
source: parsed.source,
workspace: parsed.workspace,
version: parsed.version,
schemaVersion: parsed.schemaVersion,
fetchedAt: parsed.fetchedAt,
digest: parsed.digest,
},
};
}
/** Persist a verified snapshot. Failures are non-fatal (cache is best-effort). */
export function writeSnapshotCache<T>(key: string, snapshot: FreshSnapshot<T>): void {
const storage = getStorage();
if (storage === null) return;
const stored: StoredSnapshot = {
data: snapshot.data,
source: snapshot.source,
workspace: snapshot.workspace,
version: snapshot.version,
schemaVersion: snapshot.schemaVersion,
fetchedAt: snapshot.fetchedAt,
digest: snapshot.digest,
};
try {
storage.setItem(cacheKey(key), JSON.stringify(stored));
} catch {
// Quota or serialization failures simply skip caching.
}
}
/** Drop a cached snapshot (used when a surface invalidates its cache entry). */
export function clearSnapshotCache(key: string): void {
const storage = getStorage();
if (storage === null) return;
try {
storage.removeItem(cacheKey(key));
} catch {
// Ignorable: a wedged storage entry is detected as corruption on read.
}
}
@@ -1,372 +0,0 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Task } from '@/lib/types';
import { acceptSnapshot, StaleMutationError, DEFAULT_FRESHNESS_POLICY } from './model';
import type { FreshnessFailure } from './use-fresh-collection';
import {
describeFailure,
useFreshCollection,
type FreshCollection,
type UseFreshCollectionOptions,
} from './use-fresh-collection';
import { validateProjectCollection, validateTaskCollection } from './validators';
import { projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures';
/**
* Failure-matrix coverage for the freshness seam (RI-5-001): network failure,
* auth failure, malformed response, cache corruption, stale age, schema
* mismatch, cross-workspace, recovery, and stale-action rejection — with
* negative controls proving no case yields current data or an enabled
* mutation.
*/
const NOW = 1_800_000_000_000;
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
let root: Root | null = null;
let container: HTMLDivElement;
let latest: FreshCollection<Task[]> | null = null;
function Probe({
options,
}: {
options: UseFreshCollectionOptions<Task[]>;
}): React.ReactElement | null {
latest = useFreshCollection<Task[]>(options);
return null;
}
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
beforeEach(() => {
sessionStorage.clear();
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
latest = null;
sessionStorage.clear();
vi.restoreAllMocks();
});
async function renderCollection(
options: UseFreshCollectionOptions<Task[]>,
): Promise<FreshCollection<Task[]>> {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<Probe options={options} />);
});
if (latest === null) throw new Error('hook did not run');
return latest;
}
function taskOptions(
overrides: Partial<UseFreshCollectionOptions<Task[]>> = {},
): UseFreshCollectionOptions<Task[]> {
return {
source: 'gateway:/api/tasks',
fetcher: () => Promise.resolve(taskFixtures),
validate: validateTaskCollection,
cacheKey: 'tasks',
clock: () => NOW,
...overrides,
};
}
function authError(statusCode: number): Error & { statusCode: number } {
return Object.assign(new Error(`Request failed with ${statusCode}`), { statusCode });
}
function seedCache(key: string): number {
const result = acceptSnapshot({
value: taskFixtures,
validate: validateTaskCollection,
previous: null,
policy: DEFAULT_FRESHNESS_POLICY,
source: 'gateway:/api/tasks',
now: NOW,
});
if (result.outcome !== 'accepted') throw new Error('fixture setup failed');
sessionStorage.setItem(`mosaic:freshness:v1:${key}`, JSON.stringify({ ...result.snapshot }));
return result.snapshot.version;
}
describe('useFreshCollection failure matrix', () => {
it('is unknown (not empty) while the first validation is in flight', async () => {
const deferred = createDeferred<Task[]>();
const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));
expect(collection.freshness).toBe('unknown');
expect(collection.validating).toBe(true);
expect(collection.data).toBeNull();
expect(collection.canMutate).toBe(false);
await act(async () => {
deferred.resolve(taskFixtures);
await deferred.promise;
});
});
it('becomes current with provenance after a verified fetch', async () => {
const collection = await renderCollection(taskOptions());
expect(collection.freshness).toBe('current');
expect(collection.data).toEqual(taskFixtures);
expect(collection.snapshot?.source).toBe('gateway:/api/tasks');
expect(collection.snapshot?.version).toBe(1);
expect(collection.failure).toBeNull();
expect(collection.canMutate).toBe(true);
// Verified snapshot is persisted for last-known restore.
expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeTruthy();
});
it('treats a network failure as unavailable — never an empty healthy collection', async () => {
const collection = await renderCollection(
taskOptions({ fetcher: () => Promise.reject(new Error('network down')) }),
);
expect(collection.freshness).toBe('unavailable');
expect(collection.data).toBeNull();
expect(collection.failure).toEqual({ kind: 'fetch', message: 'network down' });
expect(collection.canMutate).toBe(false);
expect(describeFailure(collection.failure)).toBe('network down');
});
it('treats an auth failure as unavailable and drops the last-known snapshot', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return call === 1 ? Promise.resolve(taskFixtures) : Promise.reject(authError(401));
},
}),
);
expect(collection.freshness).toBe('current');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('unavailable');
expect(latest?.data).toBeNull();
expect(latest?.failure?.kind).toBe('fetch');
// The previous user's data must not linger in the session cache.
expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull();
});
it('invalidates a malformed response as a schema mismatch', async () => {
const collection = await renderCollection(
taskOptions({ fetcher: () => Promise.resolve({ malformed: true }) }),
);
expect(collection.freshness).toBe('unavailable');
expect(collection.data).toBeNull();
expect(collection.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' });
expect(collection.canMutate).toBe(false);
});
it('keeps the previous snapshot as labeled stale when a later payload mismatches', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return call === 1 ? Promise.resolve(taskFixtures) : Promise.resolve('garbage');
},
}),
);
expect(collection.freshness).toBe('current');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('stale');
expect(latest?.data).toEqual(taskFixtures);
expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' });
expect(latest?.canMutate).toBe(false);
});
it('drops the snapshot when the workspace changes under it (cross-workspace)', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return Promise.resolve(
call === 1 ? projectFixtures : [{ ...projectFixtures[0], userId: 'user-2' }],
);
},
validate: validateProjectCollection as unknown as (value: unknown) => {
data: Task[];
workspace: string | null;
},
source: 'gateway:/api/projects',
}),
);
expect(collection.freshness).toBe('current');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('unavailable');
expect(latest?.data).toBeNull();
expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'cross-workspace' });
});
it('ages from current to stale and refuses mutations on stale data', async () => {
let fakeNow = NOW;
const collection = await renderCollection(
taskOptions({
clock: () => fakeNow,
policy: { staleAfterMs: 40 },
tickMs: 10,
}),
);
expect(collection.freshness).toBe('current');
// Age the snapshot past the policy and let the tick recompute.
fakeNow = NOW + 60;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 25));
});
expect(latest?.freshness).toBe('stale');
expect(latest?.data).toEqual(taskFixtures);
expect(latest?.canMutate).toBe(false);
const operation = vi.fn(async () => 'result');
await expect(latest?.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
expect(operation).not.toHaveBeenCalled();
});
it('recovers to current after a successful revalidation', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return call === 1
? Promise.reject(new Error('first attempt failed'))
: Promise.resolve(taskFixtures);
},
}),
);
expect(collection.freshness).toBe('unavailable');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('current');
expect(latest?.failure).toBeNull();
const operation = vi.fn(async (data: Task[]) => data.length);
await expect(latest?.mutate(operation)).resolves.toBe(taskFixtures.length);
expect(operation).toHaveBeenCalledOnce();
});
it('restores a cached snapshot as unverified stale data, then verifies it', async () => {
const seededVersion = seedCache('tasks');
const deferred = createDeferred<Task[]>();
const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));
// Restored data is situational awareness only: labeled stale, never
// current, and mutations are refused before verification.
expect(collection.freshness).toBe('stale');
expect(collection.data).toEqual(taskFixtures);
expect(collection.canMutate).toBe(false);
await expect(collection.mutate(vi.fn())).rejects.toBeInstanceOf(StaleMutationError);
await act(async () => {
deferred.resolve(taskFixtures);
await deferred.promise;
});
expect(latest?.freshness).toBe('current');
expect(latest?.snapshot?.version).toBe(seededVersion + 1);
});
it('never promotes corrupted cache data to current (cache corruption)', async () => {
sessionStorage.setItem('mosaic:freshness:v1:tasks', '{"data":');
const collection = await renderCollection(
taskOptions({ fetcher: () => Promise.reject(new Error('still down')) }),
);
expect(collection.freshness).toBe('unavailable');
expect(collection.data).toBeNull();
expect(collection.canMutate).toBe(false);
// The corrupted entry is dropped so it cannot come back.
expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull();
});
it('refuses mutations while unknown or unavailable — the call itself, not just the button', async () => {
const deferred = createDeferred<Task[]>();
const unknown = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));
const operation = vi.fn(async () => 'result');
await expect(unknown.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
expect(operation).not.toHaveBeenCalled();
await act(async () => {
deferred.reject(new Error('failed'));
await deferred.promise.catch(() => undefined);
});
const unavailable = latest!;
await expect(unavailable.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
expect(operation).not.toHaveBeenCalled();
expect(unavailable.canMutate).toBe(false);
});
it('degrades to stale with last-known data when a revalidation fails after success', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return call === 1
? Promise.resolve(taskFixtures)
: Promise.reject(new Error('connection lost'));
},
}),
);
expect(collection.freshness).toBe('current');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('stale');
expect(latest?.data).toEqual(taskFixtures);
const failure: FreshnessFailure | null = latest?.failure ?? null;
expect(failure).toEqual({ kind: 'fetch', message: 'connection lost' });
});
});
@@ -1,281 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
acceptSnapshot,
assertMutable,
computeFreshness,
DEFAULT_FRESHNESS_POLICY,
invalidationReasonLabels,
type FreshPayload,
type FreshSnapshot,
type FreshnessPolicy,
type FreshnessState,
type InvalidationReason,
StaleMutationError,
} from './model';
import { clearSnapshotCache, readSnapshotCache, writeSnapshotCache } from './snapshot-cache';
/**
* Freshness-aware collection fetch hook (RI-5-001).
*
* One hook owns one gateway collection end to end: fetch, schema validation,
* snapshot acceptance with provenance, session-scoped last-known caching,
* aging, and the mutation guard. Pages consume `freshness` and never infer
* health from emptiness.
*/
/** Why the latest validation did not produce a current snapshot. */
export type FreshnessFailure =
| { readonly kind: 'fetch'; readonly message: string }
| { readonly kind: 'invalidated'; readonly reason: InvalidationReason };
export interface UseFreshCollectionOptions<T> {
/** Source identity for provenance labels, e.g. `gateway:/api/tasks`. */
readonly source: string;
/** Performs the unvalidated fetch. The hook owns abort and verification. */
readonly fetcher: (signal: AbortSignal) => Promise<unknown>;
/**
* Runtime schema validator. Returning `null` invalidates the payload
* (`schema-mismatch`) instead of letting malformed JSON flow into render.
*/
readonly validate: (value: unknown) => FreshPayload<T> | null;
/** Overrides of the default freshness policy. */
readonly policy?: Partial<FreshnessPolicy>;
/**
* Session cache key for last-known snapshots. `null`/omitted disables
* restore. Restored snapshots are unverified: they render only as
* labeled `stale` data until a fetch re-verifies them.
*/
readonly cacheKey?: string | null;
/** Injectable clock for deterministic age transitions in tests. */
readonly clock?: () => number;
/** Aging tick interval override (default derived from `staleAfterMs`). */
readonly tickMs?: number;
/** When false, no fetch runs (surfaces stay `unavailable`/`unknown`). */
readonly enabled?: boolean;
}
export interface FreshCollection<T> {
/** Last verified (or restored-unverified) snapshot, or `null`. */
readonly snapshot: FreshSnapshot<T> | null;
/** Snapshot data or `null` — never a fabricated empty collection. */
readonly data: T | null;
readonly freshness: FreshnessState;
/** True while a validation request is in flight. */
readonly validating: boolean;
/** Outcome of the latest failed validation, `null` when healthy. */
readonly failure: FreshnessFailure | null;
/** False unless freshness is `current`; drives disabled UI affordances. */
readonly canMutate: boolean;
/** Re-run the fetch and re-verify. Always allowed (it is a read). */
readonly revalidate: () => Promise<void>;
/**
* Run a state-changing operation against verified-current data only.
* Rejects with `StaleMutationError` on any other state — the guard fires
* even if a disabled button was bypassed (defense in depth).
*/
readonly mutate: <R>(operation: (data: T) => Promise<R>) => Promise<R>;
}
const defaultClock = (): number => Date.now();
function resolveTickMs(policy: FreshnessPolicy, override?: number): number {
if (override !== undefined && override > 0) return override;
return Math.min(5_000, Math.max(250, Math.floor(policy.staleAfterMs / 4)));
}
function isAuthFailure(caught: unknown): boolean {
return (
typeof caught === 'object' &&
caught !== null &&
'statusCode' in caught &&
((caught as { statusCode?: unknown }).statusCode === 401 ||
(caught as { statusCode?: unknown }).statusCode === 403)
);
}
function fetchFailureMessage(caught: unknown): string {
if (caught instanceof Error && caught.message.trim().length > 0) return caught.message;
return 'The request failed.';
}
/** Human-readable summary of a failure for unavailable/stale notices. */
export function describeFailure(failure: FreshnessFailure | null): string | null {
if (failure === null) return null;
if (failure.kind === 'fetch') return failure.message;
return `The snapshot was invalidated: ${invalidationReasonLabels[failure.reason]}.`;
}
export function useFreshCollection<T>(options: UseFreshCollectionOptions<T>): FreshCollection<T> {
const optionsRef = useRef(options);
optionsRef.current = options;
const policy = useMemo<FreshnessPolicy>(
() => ({ ...DEFAULT_FRESHNESS_POLICY, ...options.policy }),
[options.policy],
);
const policyRef = useRef(policy);
policyRef.current = policy;
const clockRef = useRef(options.clock ?? defaultClock);
clockRef.current = options.clock ?? defaultClock;
const [snapshot, setSnapshot] = useState<FreshSnapshot<T> | null>(null);
const [failure, setFailure] = useState<FreshnessFailure | null>(null);
const [unverified, setUnverified] = useState(false);
const [validating, setValidating] = useState(options.enabled !== false);
const [now, setNow] = useState(() => (options.clock ?? defaultClock)());
const snapshotRef = useRef(snapshot);
snapshotRef.current = snapshot;
const failureRef = useRef(failure);
failureRef.current = failure;
const unverifiedRef = useRef(unverified);
unverifiedRef.current = unverified;
const runRef = useRef(0);
const abortRef = useRef<AbortController | null>(null);
const revalidate = useCallback(async (): Promise<void> => {
const current = optionsRef.current;
if (current.enabled === false) {
setValidating(false);
return;
}
const runId = ++runRef.current;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setValidating(true);
let value: unknown;
try {
value = await current.fetcher(controller.signal);
} catch (caught) {
if (runRef.current !== runId || controller.signal.aborted) return;
if (isAuthFailure(caught)) {
// An unauthenticated viewer must not keep (or be served) the
// previous user's last-known data.
setSnapshot(null);
setUnverified(false);
if (current.cacheKey) clearSnapshotCache(current.cacheKey);
}
setFailure({ kind: 'fetch', message: fetchFailureMessage(caught) });
setValidating(false);
return;
}
if (runRef.current !== runId) return;
const result = acceptSnapshot({
value,
validate: current.validate,
previous: snapshotRef.current,
policy: policyRef.current,
source: current.source,
now: clockRef.current(),
});
if (result.outcome === 'accepted') {
setSnapshot(result.snapshot);
setUnverified(false);
setFailure(null);
if (current.cacheKey) writeSnapshotCache(current.cacheKey, result.snapshot);
} else {
if (result.reason === 'cross-workspace') {
// Data verified for a different workspace must not linger as
// last-known situational awareness either.
setSnapshot(null);
setUnverified(false);
}
if (current.cacheKey) clearSnapshotCache(current.cacheKey);
setFailure({ kind: 'invalidated', reason: result.reason });
}
setValidating(false);
}, []);
// Restore the last-known snapshot (unverified) and run the first fetch.
useEffect(() => {
if (optionsRef.current.enabled === false) {
setValidating(false);
return;
}
const cacheKey = optionsRef.current.cacheKey;
if (cacheKey) {
const restored = readSnapshotCache<T>({
key: cacheKey,
workspace: policyRef.current.workspace,
policy: policyRef.current,
validate: optionsRef.current.validate,
});
if (restored.outcome === 'hit') {
setSnapshot(restored.snapshot);
setUnverified(true);
} else if (restored.outcome === 'invalidated') {
// A corrupted/foreign/regressed entry is dropped immediately; it must
// never surface as data. The fetch decides the visible state.
clearSnapshotCache(cacheKey);
}
}
void revalidate();
return () => {
abortRef.current?.abort();
};
// Mount-once by design: `revalidate` is stable and reads live options
// through refs, so it never needs to re-run when options change.
// Route-param pages remount this hook via an identity `key` instead.
}, [revalidate]);
// Aging tick: recomputes freshness as the snapshot ages past the policy.
useEffect(() => {
const interval = setInterval(
() => {
setNow(clockRef.current());
},
resolveTickMs(policyRef.current, optionsRef.current.tickMs),
);
return () => clearInterval(interval);
}, []);
const freshness = useMemo<FreshnessState>(() => {
if (snapshot === null) return validating ? 'unknown' : 'unavailable';
return computeFreshness({
snapshot,
policy,
now,
degraded: failure !== null || unverified,
});
// `now` from state covers age; refs inside computeFreshness are pure.
}, [snapshot, validating, failure, unverified, now, policy]);
const canMutate = freshness === 'current';
const mutate = useCallback(async <R>(operation: (data: T) => Promise<R>): Promise<R> => {
const currentSnapshot = snapshotRef.current;
// No verified snapshot at all: with nothing verified there is nothing
// current to mutate, regardless of the recorded failure.
if (currentSnapshot === null) throw new StaleMutationError('unavailable');
const state = computeFreshness({
snapshot: currentSnapshot,
policy: policyRef.current,
now: clockRef.current(),
degraded: failureRef.current !== null || unverifiedRef.current,
});
assertMutable(state);
return operation(currentSnapshot.data);
}, []);
return {
snapshot,
data: snapshot === null ? null : snapshot.data,
freshness,
validating,
failure,
canMutate,
revalidate,
mutate,
};
}
@@ -1,103 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { Mission, Project, Task } from '@/lib/types';
import {
validateMissionCollection,
validateProjectCollection,
validateProjectEntity,
validateTaskCollection,
} from './validators';
import { missionFixtures, projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures';
describe('validateTaskCollection', () => {
it('accepts a well-formed task collection', () => {
expect(validateTaskCollection(taskFixtures)).toEqual({
data: taskFixtures,
workspace: null,
});
});
it('accepts an empty collection (a healthy empty state is a valid payload)', () => {
expect(validateTaskCollection([])).toEqual({ data: [], workspace: null });
});
it.each([
['not an array', { items: [] }],
['item is not an object', ['nope']],
['missing id', [{ ...(taskFixtures[0] as Task), id: undefined }]],
['missing title', [{ ...(taskFixtures[0] as Task), title: undefined }]],
['unknown status enum', [{ ...(taskFixtures[0] as Task), status: 'finished' }]],
['unknown priority enum', [{ ...(taskFixtures[0] as Task), priority: 'urgent' }]],
['tags of the wrong type', [{ ...(taskFixtures[0] as Task), tags: 'spa' }]],
['metadata of the wrong type', [{ ...(taskFixtures[0] as Task), metadata: 'notes' }]],
['createdAt of the wrong type', [{ ...(taskFixtures[0] as Task), createdAt: 1234 }]],
['null sneaks past a required string', [{ ...(taskFixtures[0] as Task), title: null }]],
])('rejects a malformed payload: %s', (_label, value) => {
expect(validateTaskCollection(value)).toBeNull();
});
});
describe('validateMissionCollection', () => {
it('accepts a well-formed mission collection', () => {
expect(validateMissionCollection(missionFixtures)).toEqual({
data: missionFixtures,
workspace: null,
});
});
it.each([
['not an array', null],
['item missing name', [{ ...(missionFixtures[0] as Mission), name: 42 }]],
['unknown status enum', [{ ...(missionFixtures[0] as Mission), status: 'canceled' }]],
['projectId of the wrong type', [{ ...(missionFixtures[0] as Mission), projectId: 7 }]],
])('rejects a malformed payload: %s', (_label, value) => {
expect(validateMissionCollection(value)).toBeNull();
});
});
describe('validateProjectCollection', () => {
it('accepts a uniform workspace-scoped collection and reports its workspace', () => {
expect(validateProjectCollection(projectFixtures)).toEqual({
data: projectFixtures,
workspace: 'user-1',
});
});
it('accepts an empty collection with no workspace identity', () => {
expect(validateProjectCollection([])).toEqual({ data: [], workspace: null });
});
it.each([
['not an array', 42],
['item missing userId', [{ ...(projectFixtures[0] as Project), userId: undefined }]],
['unknown status enum', [{ ...(projectFixtures[0] as Project), status: 'live' }]],
['description of the wrong type', [{ ...(projectFixtures[0] as Project), description: 1 }]],
])('rejects a malformed payload: %s', (_label, value) => {
expect(validateProjectCollection(value)).toBeNull();
});
it('rejects a collection mixing workspace identities (cross-workspace leak)', () => {
const mixed = [
projectFixtures[0] as Project,
{ ...(projectFixtures[1] as Project), userId: 'user-2' },
];
expect(validateProjectCollection(mixed)).toBeNull();
});
});
describe('validateProjectEntity', () => {
it('accepts a well-formed project and reports its workspace', () => {
expect(validateProjectEntity(projectFixtures[0])).toEqual({
data: projectFixtures[0],
workspace: 'user-1',
});
});
it.each([
['not an object', 'project-1'],
['null', null],
['array', [projectFixtures[0]]],
['missing userId', [{ ...(projectFixtures[0] as Project), userId: null }]],
])('rejects a malformed entity: %s', (_label, value) => {
expect(validateProjectEntity(value)).toBeNull();
});
});
-135
View File
@@ -1,135 +0,0 @@
import type { Mission, Project, Task, MissionStatus, TaskPriority, TaskStatus } from '@/lib/types';
import type { FreshPayload } from './model';
/**
* Runtime schema validators for gateway collections (RI-5-001).
*
* `api<T>()` returns untrusted JSON cast to `T`; these validators are the
* seam where a malformed response becomes an explicit schema mismatch
* instead of flowing into the render path as if it were healthy data.
*/
const taskStatuses: readonly TaskStatus[] = [
'not-started',
'in-progress',
'blocked',
'done',
'cancelled',
];
const taskPriorities: readonly TaskPriority[] = ['critical', 'high', 'medium', 'low'];
const missionStatuses: readonly MissionStatus[] = [
'planning',
'active',
'paused',
'completed',
'failed',
];
const projectStatuses: readonly Project['status'][] = ['active', 'paused', 'completed', 'archived'];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === 'string';
}
function isOneOf<T extends string>(value: unknown, allowed: readonly T[]): value is T {
return typeof value === 'string' && (allowed as readonly string[]).includes(value);
}
function isNullableRecord(value: unknown): value is Record<string, unknown> | null {
return value === null || isRecord(value);
}
function isNullableStringArray(value: unknown): value is string[] | null {
if (value === null) return true;
if (!Array.isArray(value)) return false;
return value.every((item) => typeof item === 'string');
}
function isIsoLike(value: unknown): value is string {
return typeof value === 'string' && value.length > 0;
}
function isTask(value: unknown): value is Task {
if (!isRecord(value)) return false;
return (
isString(value['id']) &&
isString(value['title']) &&
isOneOf(value['status'], taskStatuses) &&
isOneOf(value['priority'], taskPriorities) &&
isNullableString(value['projectId']) &&
isNullableString(value['missionId']) &&
isNullableString(value['assignee']) &&
isNullableStringArray(value['tags']) &&
isNullableRecord(value['metadata']) &&
isNullableString(value['dueDate']) &&
isIsoLike(value['createdAt']) &&
isIsoLike(value['updatedAt'])
);
}
/** Tasks carry no workspace identity; scope falls back to the policy. */
export function validateTaskCollection(value: unknown): FreshPayload<Task[]> | null {
if (!Array.isArray(value) || !value.every(isTask)) return null;
return { data: value as Task[], workspace: null };
}
function isMission(value: unknown): value is Mission {
if (!isRecord(value)) return false;
return (
isString(value['id']) &&
isString(value['name']) &&
isOneOf(value['status'], missionStatuses) &&
isNullableString(value['projectId']) &&
isNullableString(value['description']) &&
isNullableRecord(value['metadata']) &&
isIsoLike(value['createdAt']) &&
isIsoLike(value['updatedAt'])
);
}
/** Missions carry no workspace identity; scope falls back to the policy. */
export function validateMissionCollection(value: unknown): FreshPayload<Mission[]> | null {
if (!Array.isArray(value) || !value.every(isMission)) return null;
return { data: value as Mission[], workspace: null };
}
function isProject(value: unknown): value is Project {
if (!isRecord(value)) return false;
return (
isString(value['id']) &&
isString(value['name']) &&
isOneOf(value['status'], projectStatuses) &&
isString(value['userId']) &&
isNullableString(value['description']) &&
isNullableRecord(value['metadata']) &&
isIsoLike(value['createdAt']) &&
isIsoLike(value['updatedAt'])
);
}
/**
* Projects are workspace-scoped: every item must carry the same `userId`.
* A collection mixing identities (cross-workspace leak) is a schema
* mismatch; the uniform `userId` becomes the snapshot workspace.
*/
export function validateProjectCollection(value: unknown): FreshPayload<Project[]> | null {
if (!Array.isArray(value) || !value.every(isProject)) return null;
const projects = value as Project[];
const workspaces = new Set(projects.map((project) => project.userId));
if (workspaces.size > 1) return null;
return { data: projects, workspace: projects.length > 0 ? projects[0]!.userId : null };
}
/** Single project entity (project detail primary collection). */
export function validateProjectEntity(value: unknown): FreshPayload<Project> | null {
if (!isProject(value)) return null;
const project = value as Project;
return { data: project, workspace: project.userId };
}
+19 -163
View File
@@ -35,7 +35,6 @@ afterEach(async () => {
document.body.replaceChildren();
root = null;
apiMock.mockReset();
sessionStorage.clear();
});
async function renderProjectDetailPage(): Promise<ReturnType<typeof createMemoryRouter>> {
@@ -65,49 +64,21 @@ function clickButtonByText(text: string): void {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
async function flushAct(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
const projectOneTasks = taskFixtures.filter((task) => task.projectId === 'project-1');
function mockHealthyLoad(): void {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(projectOneTasks);
}
describe('ProjectDetailPage', () => {
it('loads the project, tasks, missions, and optional PRD content for the active project', async () => {
mockHealthyLoad();
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
await renderProjectDetailPage();
expect(apiMock.mock.calls.map((call) => call[0])).toEqual([
'/api/projects/project-1',
'/api/missions',
'/api/tasks?projectId=project-1',
expect(apiMock.mock.calls).toEqual([
['/api/projects/project-1'],
['/api/missions'],
['/api/tasks?projectId=project-1'],
]);
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
expect(container.textContent).toContain('Mosaic Stack');
expect(container.textContent).toContain('Route /projects/:id');
expect(container.textContent).toContain('Tasks');
@@ -130,7 +101,10 @@ describe('ProjectDetailPage', () => {
});
it('opens and closes the existing read-only task modal from the tasks tab', async () => {
mockHealthyLoad();
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
await renderProjectDetailPage();
@@ -160,153 +134,35 @@ describe('ProjectDetailPage', () => {
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
it('shows verified completion verdicts when the task collection is current', async () => {
mockHealthyLoad();
await renderProjectDetailPage();
const doneCard = [...container.querySelectorAll('div')].find(
(candidate) => candidate.textContent === 'Done1',
);
expect(doneCard).toBeTruthy();
const inProgressCard = [...container.querySelectorAll('div')].find(
(candidate) => candidate.textContent === 'In Progress1',
);
expect(inProgressCard).toBeTruthy();
});
it('renders an explicit unavailable missions tab when the missions request fails (partial, not empty)', async () => {
it('renders the project with an empty missions tab when the missions request fails', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockRejectedValueOnce(new Error('Missions request failed'))
.mockResolvedValueOnce(projectOneTasks);
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
await renderProjectDetailPage();
// Secondary failure degrades the surface to partial; the project itself
// still renders.
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'partial',
);
expect(container.textContent).toContain('Mosaic Stack');
const partial = container.querySelector('[role="status"]');
expect(partial?.textContent).toContain('Missions');
expect(partial?.textContent).toContain('unavailable');
expect(container.querySelector('[role="alert"]')).toBeNull();
await act(async () => {
clickButtonByText('Missions (?)');
clickButtonByText('Missions (0)');
});
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Missions request failed');
// Negative control: a failed fetch must not look like an empty list.
expect(container.textContent).not.toContain('No missions for this project');
expect(container.textContent).toContain('No missions for this project');
});
it('marks derived verdicts unknown when the tasks collection is unavailable', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockRejectedValueOnce(new Error('Tasks request failed'));
await renderProjectDetailPage();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'partial',
);
// Completion verdicts become unknown ('?') — never green counts.
for (const label of ['Done', 'In Progress', 'Blocked', 'Tasks']) {
const unknownCard = [...container.querySelectorAll('div')].find(
(candidate) => candidate.textContent === `${label}?`,
);
expect(unknownCard, `expected ${label} card to render ?`).toBeTruthy();
}
// Negative control: no green "Done 1" verdict anywhere.
expect(
[...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'),
).toBe(false);
await act(async () => {
clickButtonByText('Tasks (?)');
});
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Tasks request failed');
// Negative control: no healthy empty task list from a failed fetch.
expect(container.textContent).not.toContain('No tasks found');
expect(container.querySelector('table')).toBeNull();
});
it('recovers a partial surface to current after revalidation', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockRejectedValueOnce(new Error('Tasks request failed'))
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(projectOneTasks);
await renderProjectDetailPage();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'partial',
);
await act(async () => {
clickButtonByText('Revalidate');
});
await flushAct();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
expect(
[...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'),
).toBe(true);
});
it("never shows one project's data on another project's route after navigation", async () => {
mockHealthyLoad();
const router = await renderProjectDetailPage();
expect(container.textContent).toContain('Mosaic Stack');
const deferred = createDeferred<(typeof projectFixtures)[number]>();
apiMock
.mockResolvedValueOnce(deferred.promise)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await act(async () => {
await router.navigate('/projects/project-2');
});
// While project-2 loads, nothing from project-1 may render on its route.
expect(container.textContent).toContain('Loading project...');
expect(container.textContent).not.toContain('Mosaic Stack');
expect(container.textContent).not.toContain('Route /projects/:id');
await act(async () => {
deferred.resolve(projectFixtures[1]!);
await deferred.promise;
});
expect(container.textContent).toContain('Agent Runtime');
expect(apiMock.mock.calls[3]?.[0]).toBe('/api/projects/project-2');
});
it('renders a visible unavailable state when the project request fails and lets the user navigate back', async () => {
it('renders a visible alert when the project request fails and lets the user navigate back', async () => {
apiMock
.mockRejectedValueOnce(new Error('Project request failed'))
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(projectOneTasks);
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
const router = await renderProjectDetailPage();
const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Project request failed');
expect(alert?.textContent).toContain('not an empty result');
expect(container.textContent).not.toContain('Mosaic Stack');
await act(async () => {
+81 -194
View File
@@ -1,30 +1,14 @@
import { useState, type ReactElement } from 'react';
import { useEffect, useState, type ReactElement } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { MissionTimeline } from '@/components/projects/mission-timeline';
import { PrdViewer } from '@/components/projects/prd-viewer';
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
import { TaskListView } from '@/components/tasks/task-list-view';
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
import {
PartialDataNotice,
StaleDataNotice,
UnavailableDataNotice,
} from '@/components/freshness/freshness-notices';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
import {
combineFreshness,
UNKNOWN_VERDICT,
verdictValue,
type FreshSnapshot,
} from '@/lib/freshness/model';
import { describeFailure, useFreshCollection } from '@/lib/freshness/use-fresh-collection';
import {
validateMissionCollection,
validateProjectEntity,
validateTaskCollection,
} from '@/lib/freshness/validators';
import { getErrorMessage } from './page-errors';
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
@@ -67,75 +51,55 @@ function TabButton({ id, label, activeTab, onClick }: TabButtonProps): ReactElem
);
}
/** Remounts per project id so no state from one project renders for another. */
export function ProjectDetailPage(): ReactElement {
const { id = '' } = useParams();
return <ProjectDetail id={id} key={id} />;
}
function ProjectDetail({ id }: { id: string }): ReactElement {
const navigate = useNavigate();
const enabled = id.length > 0;
// Primary collection gates the surface; missions and tasks are secondaries
// whose failures degrade the surface to `partial` instead of rendering
// empty healthy lists.
const project = useFreshCollection<Project>({
source: `gateway:/api/projects/${id}`,
fetcher: (signal) => api<unknown>(`/api/projects/${id}`, { signal }),
validate: validateProjectEntity,
// No last-known restore: the entity carries workspace identity that
// cannot be scope-checked before display (see ProjectsPage note).
enabled,
});
const missions = useFreshCollection<Mission[]>({
source: 'gateway:/api/missions',
fetcher: (signal) => api<unknown>('/api/missions', { signal }),
validate: validateMissionCollection,
cacheKey: enabled ? 'missions' : null,
enabled,
});
const tasks = useFreshCollection<Task[]>({
source: `gateway:/api/tasks?projectId=${id}`,
fetcher: (signal) => api<unknown>(`/api/tasks?projectId=${id}`, { signal }),
validate: validateTaskCollection,
cacheKey: enabled ? `project-tasks:${id}` : null,
enabled,
});
const [project, setProject] = useState<Project | null>(null);
const [missions, setMissions] = useState<Mission[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const surface = combineFreshness(project.freshness, [missions.freshness, tasks.freshness]);
const tasksVerified = tasks.freshness === 'current';
const projectMissions = missions.data?.filter((mission) => mission.projectId === id) ?? null;
useEffect(() => {
if (!id) {
setError('Project id is missing.');
setLoading(false);
return;
}
const retryAll = (): void => {
void Promise.all([project.revalidate(), missions.revalidate(), tasks.revalidate()]);
};
let cancelled = false;
setLoading(true);
setError(null);
if (!enabled) {
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Project</h1>
</header>
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
Project id is missing.
</div>
<button
type="button"
onClick={() => navigate('/projects')}
className="mt-4 w-fit text-sm underline"
>
Back to projects
</button>
</div>
);
}
void Promise.all([
api<Project>('/api/projects/' + id),
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
api<Task[]>('/api/tasks?projectId=' + id).catch(() => [] as Task[]),
])
.then(([loadedProject, allMissions, loadedTasks]) => {
if (cancelled) return;
setProject(loadedProject);
setMissions(allMissions.filter((mission) => mission.projectId === id));
setTasks(loadedTasks);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load project.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
if (project.freshness === 'unknown') {
return () => {
cancelled = true;
};
}, [id]);
if (loading) {
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
@@ -146,17 +110,15 @@ function ProjectDetail({ id }: { id: string }): ReactElement {
);
}
if (project.freshness === 'unavailable' || project.data === null) {
if (error || !project) {
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Project</h1>
</header>
<UnavailableDataNotice
title="This project"
detail={describeFailure(project.failure)}
onRetry={retryAll}
/>
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
{error ?? 'Project not found.'}
</div>
<button
type="button"
onClick={() => navigate('/projects')}
@@ -168,48 +130,18 @@ function ProjectDetail({ id }: { id: string }): ReactElement {
);
}
const projectTasks = tasks.data ?? null;
const filteredTasks =
projectTasks === null
? []
: taskFilter === 'all'
? projectTasks
: projectTasks.filter((task) => task.status === taskFilter);
// Derived completion verdicts: unknown (never green) unless the task
// collection is verified current.
const doneCount = projectTasks?.filter((task) => task.status === 'done').length ?? 0;
const inProgressCount = projectTasks?.filter((task) => task.status === 'in-progress').length ?? 0;
const blockedCount = projectTasks?.filter((task) => task.status === 'blocked').length ?? 0;
const prdContent = getPrdContent(project.data);
taskFilter === 'all' ? tasks : tasks.filter((task) => task.status === taskFilter);
const prdContent = getPrdContent(project);
const tabs: Array<{ id: Tab; label: string }> = [
{ id: 'overview', label: 'Overview' },
{
id: 'tasks',
label: `Tasks (${projectTasks === null ? UNKNOWN_VERDICT : projectTasks.length})`,
},
{
id: 'missions',
label: `Missions (${projectMissions === null ? UNKNOWN_VERDICT : projectMissions.length})`,
},
{ id: 'tasks', label: `Tasks (${tasks.length})` },
{ id: 'missions', label: `Missions (${missions.length})` },
...(prdContent ? [{ id: 'prd' as const, label: 'PRD' }] : []),
];
const staleSnapshot: FreshSnapshot<unknown> | null =
project.freshness === 'stale'
? project.snapshot
: missions.freshness === 'stale'
? missions.snapshot
: tasks.freshness === 'stale'
? tasks.snapshot
: null;
const missingSections: string[] = [];
if (missions.freshness === 'unavailable') missingSections.push('Missions');
if (tasks.freshness === 'unavailable') missingSections.push('Tasks');
return (
<div data-freshness={surface} className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
<button
@@ -220,64 +152,49 @@ function ProjectDetail({ id }: { id: string }): ReactElement {
Projects
</button>
<span>/</span>
<span className="text-text-primary">{project.data.name}</span>
<span className="text-text-primary">{project.name}</span>
</nav>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-text-primary">{project.data.name}</h1>
<h1 className="text-2xl font-semibold text-text-primary">{project.name}</h1>
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs',
projectStatusColors[project.data.status] ?? 'bg-gray-600/20 text-gray-400',
projectStatusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
)}
>
{project.data.status}
{project.status}
</span>
</div>
{project.data.description ? (
<p className="mt-1 text-sm text-text-muted">{project.data.description}</p>
{project.description ? (
<p className="mt-1 text-sm text-text-muted">{project.description}</p>
) : null}
<p className="mt-2 text-xs text-text-muted">
Created {new Date(project.data.createdAt).toLocaleDateString()} · Updated{' '}
{new Date(project.data.updatedAt).toLocaleDateString()}
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
{new Date(project.updatedAt).toLocaleDateString()}
</p>
</div>
</div>
</header>
{staleSnapshot !== null ? (
<div className="mb-6">
<StaleDataNotice label={staleSnapshot} onRetry={retryAll} />
</div>
) : null}
{missingSections.length > 0 ? (
<div className="mb-6">
<PartialDataNotice missing={missingSections} onRetry={retryAll} />
</div>
) : null}
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
label="Tasks"
value={projectTasks === null ? UNKNOWN_VERDICT : String(projectTasks.length)}
/>
<StatCard label="Tasks" value={String(tasks.length)} />
<StatCard
label="Done"
value={verdictValue(tasksVerified, String(doneCount))}
valueClass={tasksVerified ? 'text-success' : undefined}
value={String(tasks.filter((task) => task.status === 'done').length)}
valueClass="text-success"
/>
<StatCard
label="In Progress"
value={verdictValue(tasksVerified, String(inProgressCount))}
valueClass={tasksVerified ? 'text-blue-400' : undefined}
value={String(tasks.filter((task) => task.status === 'in-progress').length)}
valueClass="text-blue-400"
/>
<StatCard
label="Blocked"
value={verdictValue(tasksVerified, String(blockedCount))}
valueClass={tasksVerified && blockedCount > 0 ? 'text-error' : undefined}
value={String(tasks.filter((task) => task.status === 'blocked').length)}
valueClass={tasks.some((task) => task.status === 'blocked') ? 'text-error' : undefined}
/>
</div>
@@ -294,43 +211,23 @@ function ProjectDetail({ id }: { id: string }): ReactElement {
</div>
{activeTab === 'overview' ? (
<OverviewTab project={project.data} missions={projectMissions} tasks={projectTasks} />
<OverviewTab project={project} missions={missions} tasks={tasks} />
) : null}
{activeTab === 'tasks' ? (
<div>
{projectTasks === null ? (
<UnavailableDataNotice
title="Tasks"
detail={describeFailure(tasks.failure)}
onRetry={retryAll}
<div className="mb-4">
<TaskStatusSummary
tasks={tasks}
activeFilter={taskFilter}
onFilterChange={setTaskFilter}
/>
) : (
<>
<div className="mb-4">
<TaskStatusSummary
tasks={projectTasks}
activeFilter={taskFilter}
onFilterChange={setTaskFilter}
/>
</div>
<TaskListView tasks={filteredTasks} onTaskClick={setSelectedTask} />
</>
)}
</div>
<TaskListView tasks={filteredTasks} onTaskClick={setSelectedTask} />
</div>
) : null}
{activeTab === 'missions' ? (
projectMissions === null ? (
<UnavailableDataNotice
title="Missions"
detail={describeFailure(missions.failure)}
onRetry={retryAll}
/>
) : (
<MissionTimeline missions={projectMissions} />
)
) : null}
{activeTab === 'missions' ? <MissionTimeline missions={missions} /> : null}
{activeTab === 'prd' && prdContent ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
@@ -351,26 +248,18 @@ function OverviewTab({
tasks,
}: {
project: Project;
missions: Mission[] | null;
tasks: Task[] | null;
missions: Mission[];
tasks: Task[];
}): ReactElement {
const recentTasks =
tasks === null
? null
: [...tasks]
.sort(
(left, right) =>
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
)
.slice(0, 5);
const recentTasks = [...tasks]
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())
.slice(0, 5);
return (
<div className="grid gap-6 lg:grid-cols-2">
<section>
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
{recentTasks === null ? (
<UnavailableDataNotice title="Tasks" />
) : recentTasks.length === 0 ? (
{recentTasks.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
<p className="text-sm text-text-muted">No tasks yet</p>
</div>
@@ -398,9 +287,7 @@ function OverviewTab({
<section>
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
{missions === null ? (
<UnavailableDataNotice title="Missions" />
) : missions.length === 0 ? (
{missions.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
<p className="text-sm text-text-muted">No missions yet</p>
</div>
+3 -69
View File
@@ -51,7 +51,6 @@ afterEach(async () => {
document.body.replaceChildren();
root = null;
apiMock.mockReset();
sessionStorage.clear();
});
async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRouter>> {
@@ -72,22 +71,6 @@ async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRoute
return router;
}
function clickButtonByText(text: string): void {
const button = [...container.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes(text),
);
if (!button) {
throw new Error(`Button containing "${text}" not found`);
}
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
async function flushAct(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
describe('ProjectsPage', () => {
it('shows a visible loading state while the project request is in flight', async () => {
const deferred = createDeferred<typeof projectFixtures>();
@@ -108,7 +91,7 @@ describe('ProjectsPage', () => {
const router = await renderProjectsPage();
expect(apiMock.mock.calls[0]?.[0]).toBe('/api/projects');
expect(apiMock).toHaveBeenCalledWith('/api/projects');
expect(container.textContent).toContain('Mosaic Stack');
expect(container.textContent).toContain('Agent Runtime');
@@ -125,7 +108,7 @@ describe('ProjectsPage', () => {
expect(container.textContent).toContain('Project detail target');
});
it('renders the empty state only for a verified empty collection', async () => {
it('renders the empty state when the API returns no projects', async () => {
apiMock.mockResolvedValueOnce([]);
await renderProjectsPage();
@@ -134,12 +117,9 @@ describe('ProjectsPage', () => {
expect(container.textContent).toContain(
'Projects will appear here when created via the gateway API',
);
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
it('renders a failed fetch as an explicit unavailable state, never an empty collection', async () => {
it('renders a visible alert when the projects request fails', async () => {
apiMock.mockRejectedValueOnce(new Error('Projects are unavailable'));
await renderProjectsPage();
@@ -147,51 +127,5 @@ describe('ProjectsPage', () => {
const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Projects are unavailable');
expect(alert?.textContent).toContain('not an empty result');
// Negative controls: no healthy empty state and no project cards render
// from a failed fetch.
expect(container.textContent).not.toContain('No projects yet');
expect(container.textContent).not.toContain('Mosaic Stack');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'unavailable',
);
});
it('renders an auth failure as unavailable and recovers after retry', async () => {
apiMock
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { statusCode: 401 }))
.mockResolvedValueOnce(projectFixtures);
await renderProjectsPage();
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Unauthorized');
expect(container.textContent).not.toContain('No projects yet');
await act(async () => {
clickButtonByText('Retry');
});
await flushAct();
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.textContent).toContain('Mosaic Stack');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
it('renders a schema-mismatched response as unavailable, never as data', async () => {
apiMock.mockResolvedValueOnce({ results: projectFixtures });
await renderProjectsPage();
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('not an empty result');
expect(container.textContent).not.toContain('Mosaic Stack');
expect(container.textContent).not.toContain('No projects yet');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'unavailable',
);
});
});
+34 -32
View File
@@ -1,51 +1,53 @@
import { type ReactElement } from 'react';
import { useEffect, useState, type ReactElement } from 'react';
import { useNavigate } from 'react-router-dom';
import { ProjectCard } from '@/components/projects/project-card';
import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices';
import { api } from '@/lib/api';
import type { Project } from '@/lib/types';
import { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection';
import { validateProjectCollection } from '@/lib/freshness/validators';
import { getErrorMessage } from './page-errors';
export function ProjectsPage(): ReactElement {
const navigate = useNavigate();
const projects = useFreshCollection<Project[]>({
source: 'gateway:/api/projects',
fetcher: (signal) => api<unknown>('/api/projects', { signal }),
validate: validateProjectCollection,
// Projects carry workspace identity (userId) that is only knowable from
// the payload itself, so a restored entry cannot be scope-checked before
// display. Conservative choice: no last-known restore for this surface;
// cross-workspace switching is still invalidated at verification time.
});
const retry = (): void => {
void projects.revalidate();
};
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void api<Project[]>('/api/projects')
.then((response) => {
if (cancelled) return;
setProjects(response);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load projects.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return (
<div
data-freshness={projects.freshness}
className="flex min-h-screen flex-col px-4 py-6 sm:px-6"
>
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Projects</h1>
</header>
{projects.freshness === 'stale' && projects.snapshot ? (
<div className="mb-6">
<StaleDataNotice label={projects.snapshot} onRetry={retry} />
{error ? (
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
{error}
</div>
) : null}
{projects.freshness === 'unknown' ? (
{loading ? (
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
) : projects.freshness === 'unavailable' ? (
<UnavailableDataNotice
title="Projects"
detail={describeFailure(projects.failure)}
onRetry={retry}
/>
) : projects.data !== null && projects.data.length === 0 ? (
) : projects.length === 0 ? (
<div className="py-12 text-center">
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
<p className="mt-1 text-sm text-text-muted">
@@ -54,7 +56,7 @@ export function ProjectsPage(): ReactElement {
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{(projects.data ?? []).map((project) => (
{projects.map((project) => (
<ProjectCard
key={project.id}
project={project}
+1 -87
View File
@@ -3,9 +3,6 @@ import { createRoot, type Root } from 'react-dom/client';
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { taskFixtures } from './page-fixtures';
import { acceptSnapshot, DEFAULT_FRESHNESS_POLICY } from '@/lib/freshness/model';
import { writeSnapshotCache } from '@/lib/freshness/snapshot-cache';
import { validateTaskCollection } from '@/lib/freshness/validators';
const { apiMock } = vi.hoisted(() => ({
apiMock: vi.fn(),
@@ -51,7 +48,6 @@ afterEach(async () => {
document.body.replaceChildren();
root = null;
apiMock.mockReset();
sessionStorage.clear();
});
async function renderTasksPage(): Promise<void> {
@@ -76,13 +72,6 @@ function clickButtonByText(text: string): void {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
/** Flush pending promise callbacks inside the act environment. */
async function flushAct(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
describe('TasksPage', () => {
it('shows a visible loading state before the tasks request settles', async () => {
const deferred = createDeferred<typeof taskFixtures>();
@@ -143,7 +132,7 @@ describe('TasksPage', () => {
expect(container.textContent).toContain('Wire list and kanban modal interactions');
});
it('renders a failed fetch as an explicit unavailable state, never an empty healthy board', async () => {
it('renders a visible alert when the tasks request fails', async () => {
apiMock.mockRejectedValueOnce(new Error('Tasks request failed'));
await renderTasksPage();
@@ -151,80 +140,5 @@ describe('TasksPage', () => {
const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Tasks request failed');
expect(alert?.textContent).toContain('not an empty result');
// Negative controls: no board, no healthy empty-state markers, and the
// surface is marked unavailable rather than current.
expect(container.textContent).not.toContain('Not Started');
expect(container.textContent).not.toContain('No tasks');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'unavailable',
);
});
it('recovers to a current board after retrying a failed fetch', async () => {
apiMock
.mockRejectedValueOnce(new Error('Tasks request failed'))
.mockResolvedValueOnce(taskFixtures);
await renderTasksPage();
expect(container.querySelector('[role="alert"]')).toBeTruthy();
await act(async () => {
clickButtonByText('Retry');
});
await flushAct();
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.textContent).toContain('Not Started');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
it('labels restored last-known data as stale with source, version, and age until verified', async () => {
// Seed a last-known snapshot fetched five minutes ago; the page must
// render it only under an explicit staleness label while the fetch is
// still in flight.
const restored = acceptSnapshot({
value: taskFixtures,
validate: validateTaskCollection,
previous: null,
policy: DEFAULT_FRESHNESS_POLICY,
source: 'gateway:/api/tasks',
now: Date.now() - 5 * 60_000,
});
if (restored.outcome !== 'accepted') throw new Error('fixture setup failed');
writeSnapshotCache('tasks', restored.snapshot);
const deferred = createDeferred<typeof taskFixtures>();
apiMock.mockReturnValueOnce(deferred.promise);
await renderTasksPage();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'stale',
);
const banner = container.querySelector('[role="status"]');
expect(banner?.textContent).toContain('last-known');
expect(banner?.textContent).toContain('may be out of date');
expect(banner?.textContent).toContain('gateway:/api/tasks');
expect(banner?.textContent).toContain('snapshot v1');
expect(banner?.textContent).toContain('5m ago');
// Last-known data still renders as situational awareness under the label.
expect(container.textContent).toContain('Route /tasks');
expect(container.textContent).not.toContain('Loading tasks...');
// Verification lands: the banner clears and the surface becomes current.
await act(async () => {
deferred.resolve(taskFixtures);
await deferred.promise;
});
expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
});
+33 -26
View File
@@ -1,32 +1,45 @@
import { useState, type ReactElement } from 'react';
import { useEffect, useState, type ReactElement } from 'react';
import { KanbanBoard } from '@/components/tasks/kanban-board';
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
import { TaskListView } from '@/components/tasks/task-list-view';
import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { Task } from '@/lib/types';
import { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection';
import { validateTaskCollection } from '@/lib/freshness/validators';
import { getErrorMessage } from './page-errors';
type ViewMode = 'list' | 'kanban';
export function TasksPage(): ReactElement {
const tasks = useFreshCollection<Task[]>({
source: 'gateway:/api/tasks',
fetcher: (signal) => api<unknown>('/api/tasks', { signal }),
validate: validateTaskCollection,
cacheKey: 'tasks',
});
const [tasks, setTasks] = useState<Task[]>([]);
const [view, setView] = useState<ViewMode>('kanban');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const retry = (): void => {
void tasks.revalidate();
};
useEffect(() => {
let cancelled = false;
void api<Task[]>('/api/tasks')
.then((response) => {
if (cancelled) return;
setTasks(response);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load tasks.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return (
<div data-freshness={tasks.freshness} className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 flex items-center justify-between gap-4 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Tasks</h1>
<div className="flex rounded-lg border border-surface-border">
@@ -57,24 +70,18 @@ export function TasksPage(): ReactElement {
</div>
</header>
{tasks.freshness === 'stale' && tasks.snapshot ? (
<div className="mb-6">
<StaleDataNotice label={tasks.snapshot} onRetry={retry} />
{error ? (
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
{error}
</div>
) : null}
{tasks.freshness === 'unknown' ? (
{loading ? (
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
) : tasks.freshness === 'unavailable' ? (
<UnavailableDataNotice
title="Tasks"
detail={describeFailure(tasks.failure)}
onRetry={retry}
/>
) : view === 'kanban' ? (
<KanbanBoard tasks={tasks.data ?? []} onTaskClick={setSelectedTask} />
<KanbanBoard tasks={tasks} onTaskClick={setSelectedTask} />
) : (
<TaskListView tasks={tasks.data ?? []} onTaskClick={setSelectedTask} />
<TaskListView tasks={tasks} onTaskClick={setSelectedTask} />
)}
{selectedTask ? (
+1 -1
View File
@@ -22,7 +22,7 @@
| RI-1-002 | done | RI-N1 negative control: checked-in tests proving a broken mandatory check blocks every publish step and that DAG edges cannot be bypassed | #1275 | pi-glm-5.3 | mosaicstack/stack | test/ri-050-publish-gate-negative | RI-1-001 | 12K | |
| RI-2-001 | done | RI-N2 (Forge): remove stub-executor false success; `--simulate` typed `simulated` results that satisfy nothing; literal-`true` gates and echo-review replaced with real gates or typed waiting-for-authority | #1275 | pi-glm-5.3 | mosaicstack/stack | fix/ri-050-forge-fail-closed | RI-0-001 | 20K | Independent review APPROVED 2026-08-17 (Gitea review 172 on PR #1278, head 99b8f6ea; reviewing seat fargo — recorded under shared host principal mos-dt-0, provenance correction posted by fred; wrapper gap filed by fred). Executed at head: forge tests 116/116, lint green, typecheck green after building macp dist (minimal-install artifact, not a defect), workspace typecheck 45/45, no external type consumers of the changed interfaces. CI red = known lane-wide fleet-test failure only, carries no information about this change (fred, log-content analysis, pipelines 2456-2458). Non-blocking finding: README L141-143 + skills/mosaic-forge/SKILL.md document bare forge run/resume, which now fails closed — fast-follow docs touch. Merge queued behind #1270. UPDATE 2026-08-18: #1270 merged; CI GREEN at head 4917df1f via serialized retry (pipeline 2477) - root cause of prior reds was CI-agent contention (web SPA timeouts under concurrent pipelines), superseding the fleet-test-failure theory. |
| RI-2-002 | done | RI-N2 (MACP): gate runner fails closed on empty commands, stub executors, and unimplemented CI-provider gates unless explicit simulate; typed capability failures | #1275 | pi-glm-5.3 | mosaicstack/stack | fix/ri-050-macp-fail-closed | RI-0-001 | 15K | PR #1293 (head 2097379e): CI green (pipeline 2465), independent review APPROVED (Gitea review 173, jarvis seat, 2026-08-17) - macp 109/109 verified at head. Merge queued behind #1276/#1277/#1278. |
| RI-3-001 | done | RI-N4: complete probe inventory mapping every TS and shell quality-rail check to one canonical check with disposition (preserve/strengthen/retire, each named) | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-qr-probe-inventory | RI-0-001 | 12K | PR #1302 (head e06a47fac591): CI green (2484), independent review APPROVED (Gitea review 187, fargo seat, 2026-08-18) — 54 rows / 21 canonical checks / dispositions 43-2-9-0 verified by row-count and code spot-checks. Merged by fargo at pinned head. |
| RI-3-001 | in-progress | RI-N4: complete probe inventory mapping every TS and shell quality-rail check to one canonical check with disposition (preserve/strengthen/retire, each named) | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-qr-probe-inventory | RI-0-001 | 12K | |
| RI-3-002 | not-started | RI-N4: TS evaluator absorbs effective shell probes; typed results (passed/failed/blocked/error/not-applicable) with versioned digested check definitions; shell commands become thin adapters; contract/parity/negative-control tests | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-qr-evaluator | RI-3-001 | 30K | |
| RI-4-001 | in-progress | RI-N3: one PRD application service — `mission --plan` persists mission↔PRD linkage (ids/versions/selected requirements); `mosaic prdy` routes through the service or becomes a named import/export adapter; Markdown is a labeled generated view; explicit conflict-aware import | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-prd-authority | RI-0-001 | 35K | PR #1294 (head 8d258e1d): CI green (pipeline 2466), independent review APPROVED (Gitea review 174, jarvis seat, 2026-08-17) - prdy 20/20 + command specs 9/9 at head. Merge queued behind #1276/#1277/#1278. |
| RI-5-001 | done | RI-N5: typed freshness states (current/stale/partial/unknown/unavailable); no failed-fetch-renders-empty; stale derived verdicts → unknown; mutations disabled when stale; failure-matrix tests | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-web-stale-safety | RI-0-001 | 25K | |
-186
View File
@@ -1,186 +0,0 @@
# Quality-Rails Probe Inventory — RI-3-001
- **Task:** RI-3-001 (SDLC-D-037 first half; PRD § Release Integrity Workstream, RI-N4)
- **Date:** 2026-08-18
- **Base:** `origin/next` @ `8199261c` (branch `docs/ri-050-qr-probe-inventory`)
- **Follow-up:** RI-3-002 consumes the dispositions here when building the single TS evaluator.
## 0. Scope and method
Every mechanism in this repository that verifies a quality, integrity, safety, or release
property — TypeScript checks, shell probes, pipeline steps, git hooks, and installer-side
assertions — gets one row. Each row's "what it actually verifies" was written from the
probe's **code**, not its name or docs. Framework tool unit/regression suites (git wrappers,
wake, tmux, orchestrator, …) are treated as one enforcement surface (`test:framework-shell`)
because they test tool behavior rather than repo quality; their wiring integrity is itself
guarded by `check-test-enumeration.sh`, and the quality-relevant members are rowed
individually.
**Kinds:** `ts` (TypeScript/Node check), `shell` (bash/python probe), `pipeline-step`
(exists only inside a Woodpecker pipeline).
**Enforcement points:** `local` (operator-invoked), `pre-commit`, `pre-push`,
`CI ci.yml#<step>`, `publish.yml#<step>` (CI on push to main/next), `turbo <task>`,
`agent-runtime` (framework hooks on an agent host), `installer` (host install path),
`unwired`.
**Dispositions** (recommendations for RI-3-002): `preserve` (keep as-is; already the
canonical or a correct guard-of-the-guard), `strengthen` (keep, but a concrete gap must
close — usually absorption into the TS evaluator), `strengthen (review)` (viable retirement
candidate once the evaluator absorbs it; do not retire yet). Note: RI-N4 requires that
effective shell probes be **absorbed before** their independent paths retire — no row here
is marked `retire` because no absorption exists yet.
## 1. Inventory
### 1.1 Repo-level gate tasks (pnpm / turbo)
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
| ------------------------------------- | ------------------------------------------------------------------------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pnpm preflight` (checkout preflight) | `scripts/preflight.mjs` | ts | Six gate binaries (eslint, husky, prettier, tsc, turbo, vitest) exist and are executable in `node_modules/.bin` (exit 42 if not); no stale `.mosaic-test-work/web-build.lock` (exit 43); `apps/web/.next` is a real directory (not a symlink), every entry owned by the current uid, and its `.mosaic-source-hash` fingerprint + `.mosaic-symlink-manifest` hash match the certified build written by `scripts/build-web.mjs` | `pre-push`; inside `pnpm typecheck` (→ `CI ci.yml#typecheck`, verify-release `typecheck` stage) | QC-1 Checkout integrity | preserve | Blocks a poisoned/stale generated `.next` from faking a green typecheck (the five-month-stale-`.next` class); trust chain is self-contained per-checkout. |
| `pnpm typecheck` | root `package.json``turbo run typecheck` | ts | Per-package `tsc --noEmit` (all 20 packages); turbo `typecheck` depends on `^build`, so package builds must succeed first; prefixed by checkout preflight | `CI ci.yml#typecheck`; `pre-push`; verify-release `typecheck` stage; `turbo typecheck` | QC-2 Workspace typecheck | preserve | The single workspace-wide type gate; CI and hooks invoke the same task, no divergent checklist. |
| `pnpm lint` | root `package.json``turbo run lint` | ts | Per-package `eslint src` under root `eslint.config.mjs` (ignores `dist`, `.next`, `framework/**`, etc.) | `CI ci.yml#lint`; `pre-push`; verify-release `lint` stage; `turbo lint` | QC-3 Workspace lint | preserve | Same-task invocation from every surface; no second lint definition. |
| `pnpm format:check` | root `package.json``prettier --check` | ts | Prettier parse/format equality over `**/*.{ts,tsx,js,jsx,json,md}` minus `.prettierignore` (generated trees, `docs/scratchpads/`, venvs, …) | `CI ci.yml#format`; `pre-push`; verify-release `format` stage | QC-4 Format check | preserve | Single formatter, single ignore list, enforced identically everywhere. |
| `pnpm test` | root `package.json` `test` = `test:checkout` && `turbo run test` && `test:installer` | ts | (a) `node --test scripts/*.test.mjs` — checkout-tool units; (b) per-package `vitest run` (mosaic appends the 47-command `test:framework-shell` chain); (c) `tools/install-next-lane.test.sh`; turbo `test` declares DB env vars and depends on `^build` | `CI ci.yml#test` (with `DATABASE_URL` + `db:migrate` first); verify-release `test` stage; `turbo test` | QC-5 Test suite execution | preserve | One composed test command; the chain property (any link red ⇒ step red) is the gate. |
| `pnpm build` | root `package.json``turbo run build` | ts | Per-package build (`tsc`/Next) with `^build` dependency and `dist/**` outputs | `publish.yml#build`; verify-release `build` stage; `turbo build` | QC-6 Workspace build | preserve | Publish artifacts derive from the same build task CI verifies. |
### 1.2 Framework quality shell probes (`packages/mosaic/framework/tools/quality/`)
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
| ------------------------------------------- | ----------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| Sanitization gate | `scripts/verify-sanitized.sh` | shell | Built-in self-test first (planted identity/structural/YAML+service fixtures; exit 2 if the regexes or extension coverage break), then: (1) identity denylist grep (`jarvis\|jason\|woltje\|brain.woltje.com\|/home/jwoltje\|\bPDA\b`) over all shipped text files **including** `examples/`; (2) structural grep for private `$HOME/src` defaults in shipped scripts **excluding** `examples/`. Any hit ⇒ exit 1 | `CI ci.yml#sanitization`; verify-release `sanitization` stage | QC-7 Framework sanitization | preserve | Labeled one-time regression guard with a self-test that prevents silent no-op; correctly scoped (identity vs structural) and documented as not a general PII detector. |
| Resident-context budget | `scripts/check-resident-budget.sh` (+ `--self-test`) | shell | Self-test of the comparator, then `wc -l` vs per-file ceilings (CONSTITUTION 120, AGENTS 120, each RUNTIME.md 90); missing file ⇒ fail; over ceiling ⇒ exit 1 | `CI ci.yml#sanitization` (both modes); verify-release `sanitization` stage | QC-8 Resident-context budget | preserve | Caps the container (lines), never the wording — the deliberate anti-drift design (DESIGN §7); CI-enforceable half only, by design. |
| Test-membership enumeration guard (#1017) | `scripts/check-test-enumeration.sh` + `test-enumeration-exclusions.txt` | shell | Parses surface S1 (`packages/mosaic` `test:framework-shell` via JSON+shlex) and S2 (every `framework/tools/\*.sh | .py`token in`ci.yml`, comment lines stripped); population = `_test_.sh`under`framework/tools`; FAILS on: suite-shaped file on disk neither enumerated nor signed-excluded; surface naming a path missing on disk (both directions); exclusion without reason / stale / outside population / contradicting enumeration. Proves **naming, not reachability** (stated in-file) | `CI ci.yml#sanitization` (direct line); link [0] of `test:framework-shell` (thus `CI ci.yml#test`); verify-release `sanitization` stage | QC-9 Test-membership enumeration | preserve | Makes silent under-run impossible; invoked from both surfaces it audits so severing the chain cannot silence it. |
| Enumeration-guard needles | `scripts/test-check-test-enumeration.sh` | shell | Needle/control fixtures driven through `--root`: every promised failure mode must trip the guard **on its own words**, plus controls that must pass (null-case defense); covers commented-out ci.yml lines (F1) and line-range parsing (n2b) | `test:framework-shell``CI ci.yml#test`; verify-release `test` stage | QC-9 Test-membership enumeration | preserve | Guard-of-the-guard with both polarities; same canonical check by design. |
| Upgrade manifest guard (#791 HARD GATE) | `scripts/test-upgrade-manifest-guard.sh` | shell | Keep-mode `install.sh` upgrade against seeded throwaway `MOSAIC_HOME`: every operator sentinel — including an **unanticipated** one — survives byte-identical with unchanged mtime; framework files still update; retired framework files pruned; matrix run with rsync present AND absent (keep path must be rsync-independent); fail-closed matrix (empty/operator-only/malformed/missing manifest aborts loudly, operator files untouched); operator secret never appears in installer output | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | The operator-data hard gate for the `mosaic update` path; negative controls are load-bearing and documented. |
| Upgrade rollback gate (#791 B1) | `scripts/test-upgrade-rollback.sh` | shell | Mid-sync failure (PATH-shadowing `cp` shim) must trigger snapshot restore: restore message fires, corrupted file restored, target byte-identical to pre-upgrade; control installer with `set -E` stripped must NOT roll back (proves errtrace is load-bearing); plus signal/exit-guard controls | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | Proves the rollback trap actually fires; the `-E`-stripped control keeps Part A honest. |
| Durable-snapshot gate (#791 PR2) | `scripts/test-upgrade-durable-snapshot.sh` | shell | Pre-update snapshot taken before any mutation (0700/0600 perms, secret never logged, retention-pruned); post-sync verify net restores operator files a manifest bug lets the sync touch; CWE-59 symlink-leaf guard proven with a portable cp shim in both polarities (write-through-link must not happen); v1→v2 migration semantics (intended `bin/` removal not healed) | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | Covers tampering and leak vectors the manifest guard cannot see; the shim rationale (busybox vs GNU cp) is documented in-file. |
| Install migration matrix (v2→v3) | `scripts/test-install-migration.sh` | shell | Fixture matrix running the real installer with `MOSAIC_SYNC_ONLY=1`: fresh install seeds + stamps version 3; legacy user-edited AGENTS overwritten with `.pre-constitution.bak` preserved (and idempotent); tuned STANDARDS overwritten; operator files (SOUL, credentials) preserved. Mirrors the TS suite `packages/mosaic/src/config/file-adapter.test.ts` — both installers must behave identically | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | Pins the shell/TS installer parity contract; removal would orphan that parity requirement. |
| Enforcement verification probe (bash) | `scripts/verify.sh` | shell | Attempts **real commits** in the target repo: planted type error must produce a commit blocked with `error`; planted `any` must trip `no-explicit-any`; planted lint error must trip `prettier`; gitleaks binary must exist (3a) and detect a planted AWS key via `gitleaks git --pre-commit --staged --redact` (3b). Verdicts are output-grep matches on hook stderr | `local` via installed `mosaic-quality-verify` on scaffolded target projects; **not run in this repo's CI** | QC-20 Downstream enforcement verification | strengthen (review) | Mechanism is genuinely behavioral (stronger than file presence) but verdict logic is grep-on-output and it is unwired here; absorb as the evaluator's enforcement-probe check (the RI-N4 evaluator invokes it or reimplements it) before retiring the shell path. |
| Enforcement verification probe (PowerShell) | `scripts/verify.ps1` | shell | Windows port of `verify.sh`: same planted-commit tests with `$output -match` matching; no gitleaks self-test parity beyond the same checks | `local` (Windows operator); no Windows CI runner exists | QC-20 Downstream enforcement verification | strengthen (review) | A hand-maintained twin of `verify.sh` with no CI coverage — exactly the drift shape the single evaluator removes; retire after the TS evaluator owns the probe. |
| Quality template installer (bash) | `scripts/install.sh` | shell | Copies template files (`.husky/pre-commit` incl. mandatory gitleaks, `.lintstagedrc.js`, `.eslintrc.js`, `tsconfig.json`, `.woodpecker.yml`, `.gitleaks.toml`) into a target project; **warns** (does not verify) about `package.json` snippet merge; no post-condition check | `local` / via `mosaic-quality-apply` | QC-21 Downstream rails scaffolding | strengthen (review) | Duplicates the TS `quality-rails init` scaffolder for a different template set; converging on one scaffolder (with post-scaffold verification) is prerequisite to retiring this path. |
| Quality template installer (PowerShell) | `scripts/install.ps1` | shell | Windows twin of the template copy above | `local` (Windows operator) | QC-21 Downstream rails scaffolding | strengthen (review) | Same twin-drift risk as `verify.ps1`; no runner exercises it. |
| `mosaic-quality-verify` adapter | `framework/tools/_scripts/mosaic-quality-verify` | shell | Thin adapter: validates target dir exists, asserts `verify.sh` present+executable, `cd` target, exec it. No verdict logic of its own | `local` (installed framework bin) | QC-20 Downstream enforcement verification | preserve | Already the thin-adapter shape RI-N4 prescribes for shell surfaces. |
| `mosaic-quality-apply` adapter | `framework/tools/_scripts/mosaic-quality-apply` | shell | Thin adapter: arg validation then exec of quality `install.sh --template … --target …` | `local` (installed framework bin) | QC-21 Downstream rails scaffolding | preserve | Thin adapter, no separate verdict; disposition follows its target script's convergence. |
| Roster schema regression | `scripts/test-roster-schema.py` | shell | jsonschema `Draft202012Validator` over `fleet/roster.schema.json` with valid/invalid connector-kind fixtures (tmux/discord/matrix conditional fields) | **unwired** — not on S1 or S2, not signed-excluded; also outside the enumeration guard's `*.sh` population, so the guard cannot see it | QC-5 Test suite execution | strengthen (review) | A real regression suite that currently runs nowhere; wire it into a CI surface or sign an exclusion — leaving it invisible re-arms the exact gap #1017 closed. |
| Framework shell chain (S1) | `packages/mosaic/package.json` `test:framework-shell` | shell | 47-command `&&` chain: enumeration guard + needles, 14 lease-broker/mutator-gate python unitests, `check-runtime-launches.py`, and ~30 framework-tool shell suites (git wrappers, wake, woodpecker, tmux, glpi, orchestrator, `_scripts`). Quality-relevant members rowed separately below | `turbo test``CI ci.yml#test`; verify-release `test` stage | QC-5 Test suite execution | preserve | The chain is the execution surface the enumeration guard audits; known residuals: a failing link stops later suites (measured in #1270 — suites after position 44 had not run), and the guard proves naming, not reachability. |
### 1.3 Framework runtime hooks and their harnesses (agent-host enforcement)
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
| ------------------------------------- | ----------------------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | --------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| QA edit hook seam | `framework/tools/qa/qa-hook-stdin.sh` (+ `qa-hook-handler.sh`) | shell | PostToolUse stdin hook: extracts edited file from the tool JSON (jq or grep fallback), skips non-JS/TS, then the deps-preflight gate — exits 1 with the legible sentinel `deps not installed — run pnpm install` when `node_modules/.bin` is missing/empty (the #856 false-red class); the downstream handler only files QA remediation **report templates** (no verification logic) | `agent-runtime` (framework `runtime/claude/settings.json` PostToolUse); never CI | QC-16 Agent-runtime edit-time checks | strengthen (review) | The sentinel gate is real enforcement; the handler's report-filing adds no verdict and its name promises more than the code does — evaluator absorption should keep the sentinel, drop the report theater. |
| Typecheck-on-edit hook | `framework/tools/qa/typecheck-hook.sh` | shell | PostToolUse: for edited `.ts/.tsx`, finds nearest `tsconfig.json` and runs `tsc --noEmit`, surfacing errors nonzero to the agent immediately | `agent-runtime` (framework `runtime/claude/settings.json` PostToolUse) | QC-16 Agent-runtime edit-time checks | strengthen (review) | Edit-time duplicate of QC-2 with independent invocation logic; keep behavior, converge invocation through the evaluator adapter. |
| Deps-preflight harness | `framework/tools/qa/test-deps-preflight.sh` | shell | Five assertions against the seam incl. a documented RED control (raw `not found`), sentinel behavior for missing and empty `.bin`, and no-false-positive once populated | `test:framework-shell``CI ci.yml#test` | QC-16 Agent-runtime edit-time checks | preserve | Guard-of-the-check with a red control; keeps the sentinel from regressing. |
| Prompt-helper RCE regression | `framework/tools/_scripts/test-mosaic-init-rce.sh` | shell | Sources the prompt helpers and proves a literal `$(touch /tmp/pwned)` answer round-trips verbatim and never executes (no `/tmp/pwned` created) | `test:framework-shell``CI ci.yml#test` | QC-5 Test suite execution | preserve | Cheap, load-bearing security regression on the installer's input path. |
| Install-ordering harness (#869 C2) | `framework/tools/_scripts/test-install-ordering-guard.sh` | shell | Drives `mosaic-link-runtime-assets` with a fake `mosaic` on PATH: probe ok ⇒ settings copied + exit 0; probe fail ⇒ exit 1 with degraded outcome but all other runtime files still copied; `--allow-inactive-enforcement` forwarded; no-mosaic-on-PATH ⇒ python3 fallback strips enforcement hooks and exits 1; fallback + flag ⇒ wires as-is, exit 0 | `test:framework-shell``CI ci.yml#test` | QC-17 Lease-enforcement wiring safety | preserve | Exercises the shell wiring seam independently of the TS guard's own spec suite (complementary coverage, by design). |
| Fleet-transport harness (#1240) | `framework/tools/_scripts/test-fleet-transport-check.sh` | shell | Extracts the shipped `check_fleet_transport`/`fleet_declared_transport` functions **from the shipped scripts** (fails loud if extraction yields nothing) and drives both implementations (mosaic-doctor + `tools/install.sh`) from one case table | `test:framework-shell``CI ci.yml#test` | QC-18 Operator-host drift audit | preserve | The anti-drift harness for the one rule shipped twice; extraction-from-source keeps it from testing a stale copy. |
| Terminal-green contract (RM-61/#1000) | `framework/tools/woodpecker/test-terminal-green-contract.sh` + `verify-terminal-green.py` | shell | Red-first fixtures: pipeline JSON variants (service failure, step failure, cancelled, etc.) must produce the correct terminal-green verdict; controls must pass | `test:framework-shell``CI ci.yml#test` | QC-5 Test suite execution | preserve | Keeps the CI-wait wrapper's green-detection honest; a false green here would poison every merge gate that trusts `pr-ci-wait.sh`. |
| Lease-gate launch invariant | `framework/tools/lease-broker/check-runtime-launches.py` | shell | Scans production roots (`packages/`, `apps/`, `plugins/`, `tools/`) across sh/py/ts/yaml suffixes for Claude/Pi process launches **outside** the lease gate; allowlist-based; fails CI on violation | `test:framework-shell``CI ci.yml#test` | QC-15 Lease-gate architecture invariant | preserve | The only architectural "no ungated launches" rail; grep+allowlist is the right cost/benefit for this invariant. |
### 1.4 TypeScript quality logic (`@mosaicstack/quality-rails` + mosaic CLI)
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `quality-rails check` | `packages/quality-rails/src/cli.ts` (`mosaic quality-rails check --project`) | ts | **Expected-file presence only**: loops `expectedFilesForKind` (node: `.eslintrc`, `biome.json`, `.githooks/pre-commit`, `PR-CHECKLIST.md`; python: `pyproject.toml`+hooks+checklist; rust: `rustfmt.toml`+…) and exits 1 listing missing paths. Does not execute any linter, formatter, hook, or scanner | `local` (operator CLI); **no CI wiring in this repo** | QC-19 Downstream rails presence check | strengthen | This is the RI-N4 evaluator seed. Today presence ≠ parity (explicitly called out by RI-N4): it must grow typed verdicts (`passed/failed/blocked/error/not-applicable`), check versioning/subject/reason, digested definitions, and absorb the effective shell probes (QC-20 first). |
| `quality-rails doctor` | `packages/quality-rails/src/cli.ts` | ts | Same presence data as `check`, printed with ok/missing lines; **cannot fail** (no nonzero exit on missing files) | `local` (operator CLI) | QC-19 Downstream rails presence check | strengthen | A doctor that cannot fail is advisory; fold into `check` (or return typed states) when the evaluator lands. |
| `quality-rails init` | `packages/quality-rails/src/cli.ts` + `scaffolder.ts`/`templates.ts` | ts | Scaffolds rails files per detected kind/profile (linters/formatters lists are advisory strings; hooks flag always true); writes files, prints follow-ups — no post-condition verification | `local` (operator CLI) | QC-21 Downstream rails scaffolding | strengthen (review) | Second scaffolding path alongside quality `install.sh` (§1.2); converge on one with post-scaffold verification before retiring either. |
| Lease activation probe (#869 C1, hidden) | `packages/mosaic/src/commands/lease-activation-probe.ts` | ts | Real capability probe, not file presence: resolves the installed mosaic CLI and requires it to advertise the exact `{name, version}` activation contract; all deps injectable; registered as hidden CLI command and consumed by C2/C5 | `local` (hidden CLI + consumed by C2/C5); spec-tested via `lease-activation-probe.spec.ts` in `turbo test` | QC-17 Lease-enforcement wiring safety | preserve | The versioned-contract probe is precisely the fail-closed capability check RI-N2 generalizes; already typed and injectable. |
| Install-ordering guard (#869 C2, hidden) | `packages/mosaic/src/commands/install-ordering-guard.ts` | ts | Decides whether enforcement hook entries are written into the `~/.claude/settings.json` the framework reseed ships: not activatable ⇒ strip hooks + nonzero loud outcome (default); explicit per-invocation `--allow-inactive-enforcement` opt-out wires-with-warning. Never touches the runtime gate's own fail-closed behavior | `installer` (framework reseed via `mosaic-link-runtime-assets`); spec + shell harness coverage in `turbo test` | QC-17 Lease-enforcement wiring safety | preserve | Correct default-deny with an explicit, non-env opt-out; test-locked from both the TS and shell sides. |
| Lease doctor check (#869 C5) | `packages/mosaic/src/commands/lease-doctor-check.ts` | ts | Combines hook-wiring detection in `~/.claude/settings.json` with C1 activatable and C3 broker-supervisor health: wired ∧ (¬activatable ¬healthy) ⇒ loud `[ERROR]` that forces `mosaic doctor` exit 1 regardless of the bash audit's own exit | `local` (inside `mosaic doctor`); spec coverage in `turbo test` | QC-17 Lease-enforcement wiring safety | preserve | Closes the "bricked host looks green" hole; cannot be masked by the bash script — that composition is the point. |
| `mosaic doctor` (framework drift audit) | `packages/mosaic/src/commands/launch.ts` (`doctor`) + `framework/tools/_scripts/mosaic-doctor` | shell+ts | Bash audit of the installed framework home: ~40 expected files/dirs present; runtime files are copies (not symlinks) matching source (`cmp`) or composed runtime-contract markers; hard-gates block present in AGENTS.md; sequential-thinking MCP configured; fleet transport binary present per roster (warn); legacy symlink trees gone; skills synced — **warn-based, exit 1 only with `--fail-on-warn`**, plus C5's forced error | `local` (operator audit) | QC-18 Operator-host drift audit | preserve | Host-state audit CI cannot see (user files by design, DESIGN §7); advisory exit is the documented contract — do not silently change it. |
| `mosaic gateway doctor` | `packages/mosaic/src/commands/gateway-doctor.ts` | ts | Probes per-service health (PostgreSQL, Valkey, pgvector) via `@mosaicstack/storage`, reports tier and JSON; exit 1 only when at least one **required** service fails (yellow stays 0) | `local` (operator) | QC-18 Operator-host drift audit | preserve | Service health with correct red/yellow exit semantics; JSON mode exists for scripting. |
| `mosaic gateway verify` | `packages/mosaic/src/commands/gateway/verify.ts` | ts | Post-install liveness: daemon meta via HTTP with retries, admin token on file, bootstrap endpoint reachable; aggregated pass/fail | `local`; consumed by `tools/e2e-install-test.sh` | QC-18 Operator-host drift audit | preserve | The first-run proof the installer E2E relies on; retry-aware so startup races don't false-red. |
| `mosaic fleet doctor` | `packages/mosaic/src/commands/fleet-reconciler-command.ts` | ts | Classifies local roster-owned drift (no mutation) from the parsed v2 roster | `local` (operator) | QC-18 Operator-host drift audit | preserve | Dry-run classification is the correct non-mutating audit shape. |
### 1.5 Git hooks (developer machine)
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
| ------------------------- | --------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- |
| Pre-commit staged hygiene | `.husky/pre-commit``npx lint-staged` (`.lintstagedrc`) | shell | On staged files only: `prettier --write` + `eslint --fix` for ts/tsx/js/jsx; `prettier --write` for json/md/yaml/yml. **Mutating** (fixes and re-stages); commit blocks only if a fixer itself fails | `pre-commit` (every local commit; hooks activated by `install-hooks.mjs` via `core.hooksPath .husky/_`) | QC-13 Staged-change hygiene | preserve | Correct scoped fast gate; note it auto-fixes rather than rejects (deliberate). Gap: no secret scan here — see §3. |
| Pre-push gate | `.husky/pre-push` | shell | `pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check` (no test run — documented in AGENTS.md) | `pre-push` | QC-14 Pre-push gate | preserve | Composes QC-1..4 exactly as specified in AGENTS.md; tests intentionally left to CI. |
| Hook installer | `scripts/install-hooks.mjs` (`pnpm prepare`) | ts | Stages husky hooks into a scratch repo first, asserts husky produced its `h` shim, quarantines incomplete previous sets, verifies idempotence via full directory snapshot comparison, then sets `core.hooksPath`; skips cleanly with `HUSKY=0` or no git | `installer` (runs on `pnpm install`) | QC-13 Staged-change hygiene | preserve | Self-verifying wiring for the hook gates — a corrupted half-install cannot silently disable them. |
### 1.6 CI pipeline steps (`.woodpecker/`)
Step-to-probe mapping for container steps: `ci.yml#sanitization` = QC-7+QC-8+QC-9 (rows §1.2, plus `apk add bash` env prep); `ci.yml#upgrade-guard` = QC-10 (rows §1.2, plus `apk add rsync`); `ci.yml#typecheck`/`#lint`/`#format`/`#test` = QC-2/3/4/5 (rows §1.1). Rows below are mechanisms that exist only in a pipeline.
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
| -------------------------------------- | -------------------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
| Frozen install | `ci.yml#install` | pipeline-step | `pnpm install --frozen-lockfile --prefer-offline` against the baked ci-base store — lockfile supply integrity; a drifted lockfile fails the build before any gate runs | `CI ci.yml#install` | QC-1 Checkout integrity | preserve | Lockfile-pinned dep resolution is the supply-chain floor under every later gate. |
| Test-step readiness prelude | `ci.yml#test` prologue | pipeline-step | Installs pinned `@earendil-works/[email protected]` (Invariant R suite requires the real binary) + openssl; waits up to 60×1s on `pg_isready` for the `ci-postgres` service and fails fast if it never comes up; runs `db:migrate` before tests | `CI ci.yml#test` | QC-5 Test suite execution | preserve | Fail-fast environment preconditions — a missing service produces a legible failure, not a wall of red tests. |
| Publish verify step (pending RI-1-001) | `publish.yml#verify` (branch `feat/ri-050-publish-gate` @ `46784c8d`, not yet on next) | pipeline-step | (a) Commit identity: fails closed if `CI_COMMIT_SHA` empty, `git rev-parse HEAD` empty, or the two differ; (b) runs the canonical `pnpm verify:release`. **Every publish effect depends on this step; it carries no path filter** | `publish.yml#verify` | QC-11 Terminal release verification | preserve | The RI-N1 exact-commit binding; until it merges, publish steps on next depend on `build` only (see §3 gap 1). |
| Publish error classification | `publish.yml#publish-npm` | pipeline-step | Publishes `@mosaicstack/*` (minus web) and classifies outcome: success, or the **only tolerated failure** = already-published (EPUBLISHCONFLICT / "cannot publish over" / "previously published"); explicit fatal on npm `E404/E401/ENEEDAUTH/ECONNREFUSED/ETIMEDOUT/ENOTFOUND` and on any unrecognized failure (replacing the old ` | | echo` that hid a registry 404) | `publish.yml#publish-npm` (main/tags, path-filtered on `packages/**`) | QC-12 Publish-effect integrity | preserve | Converts silent publish fall-on-floor into loud failure; allowlist-of-one error tolerance is the right shape. |
| Next-lane publish assertions | `publish.yml#publish-next-npm` | pipeline-step | Guards: branch must be `next`, `CI_PIPELINE_NUMBER` required; registry dist-tags JSON must be usable; walks all manifests, strictly parses stable semver, rewrites `X.Y.(Z+1)-next.<N>`; publishes with `--tag next` (never latest); post-publish asserts `npm view @mosaicstack/mosaic@next` resolves to the exact expected version | `publish.yml#publish-next-npm` (push/manual on next) | QC-12 Publish-effect integrity | preserve | Durable prerelease lane with end-to-end resolution proof — the published artifact is verified, not assumed. |
| Image destination policy | `publish.yml#build-gateway` / `#build-appservice` / `#build-web` | pipeline-step | Kaniko builds with destination policy: `next` ⇒ sha-tag only (fatal if a tag event sneaks in); `main` ⇒ sha + `latest`; tag events ⇒ sha + `<tag>`; anything else fatal. Path filters only skip **effects**, never the verify step | `publish.yml#build-*` | QC-12 Publish-effect integrity | preserve | Fail-closed tagging matrix; the exclude-list default-safe design keeps stale images impossible. |
Adjacent pipeline surface (not a probe): `.woodpecker/ci-image.yml` rebuilds the ci-base image on `pnpm-lock.yaml`/`Dockerfile.ci` change with an immutable `lock-<hash>` tag; pipelines consume `:latest`. Recorded for completeness — no code-quality property is checked.
### 1.7 Root installer tooling (`tools/`)
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
| --------------------------- | --------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Next-lane installer test | `tools/install-next-lane.test.sh` (`pnpm test:installer`) | shell | Drives `tools/install.sh --next` with faked `node`/`npm` binaries (no network): Node 20 must be rejected; installs must pin **exact** versions (mutable `@next` forbidden); fast path must not unexpectedly fall back to source; gateway-install failure takes the documented fallback | `turbo`-external tail of `pnpm test``CI ci.yml#test` | QC-5 Test suite execution | preserve | Hermetic (shimmed) regression net for the installer lane; runs as part of the standard test command. |
| Clean-container install E2E | `tools/e2e-install-test.sh` | shell | Full first-run flow in a node:22-alpine container: `install.sh --yes``mosaic wizard` (non-interactive) → `mosaic gateway install``mosaic gateway verify` exit check (with EXPECTED-SKIP if the installed CLI predates `gateway verify`); skips gracefully without Docker | `local` (manual; requires Docker); **not wired in CI** | QC-5 Test suite execution | strengthen (review) | The only end-to-end proof of the install→verify path; currently operator-initiated only — wire into a periodic/manual CI lane or sign its exclusion explicitly. |
| Host installer advisories | `tools/install.sh` (`--check`; `check_fleet_transport`) | shell | `--check` = version comparison only, no install; `check_fleet_transport` warns (non-blocking, by design — tmux is the fleet's dependency, not mosaic's) when the roster-declared transport binary is absent, naming exactly what it blocks; PATH-persistence warnings | `installer` (operator-run) | QC-18 Operator-host drift audit | preserve | Advisory-by-design warnings; the parallel doctor check is drift-tested by §1.3's harness. |
### 1.8 Pending workstream additions (branch `feat/ri-050-publish-gate` @ `46784c8d`)
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
| ------------------------------- | ---------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------- | ----------- | ---------------------------------------------------------------------------------------------- |
| Canonical terminal verification | `scripts/verify-release.mjs` (`pnpm verify:release`) | ts | One command replaying the full mandatory set as stages — sanitization, upgrade-guard, typecheck (incl. preflight), lint, format, test, build — mirroring `ci.yml` step-for-step; fail-fast on first failing command; requires `bash`+`rsync` on PATH; `--stage <name>` for wiring smoke-tests only | `publish.yml#verify` (pending); `local` (`pnpm verify:release`) | QC-11 Terminal release verification | preserve | The RI-N1 canonical command — CI and publication share one semantic checklist by construction. |
| Verify-parity contract test | `scripts/verify-release.test.mjs` | ts | Parses the real `ci.yml`/`publish.yml`: stage table must match ci.yml step-for-step; every publish-effect step (name `publish*` or image-pushing) must transitively depend on `verify`; commit-identity assertion must be present; `verify` must carry no path filter | `test:checkout``CI ci.yml#test` (once merged) | QC-11 Terminal release verification | preserve | Guard-of-the-guard at checkout time — the two surfaces cannot drift apart silently. |
## 2. Canonical check set
The deduplicated checks every row above maps onto. IDs are stable for RI-3-002 to consume.
- **QC-1 Checkout integrity.** Owns: the checkout can run its gates — frozen-lockfile dependency resolution, required gate binaries present, no stale build lock, and the `apps/web/.next` generated-state trust chain (real directory, uid ownership, certified source fingerprint, certified symlink manifest). Implemented by `scripts/preflight.mjs` + frozen install steps.
- **QC-2 Workspace typecheck.** Owns workspace-wide TypeScript soundness: per-package `tsc --noEmit` over built dependencies (`turbo typecheck`). The single definition invoked by CI, pre-push, and terminal verification.
- **QC-3 Workspace lint.** Owns static-analysis policy: per-package ESLint under the root config. One config, one task, every surface.
- **QC-4 Format check.** Owns formatting uniformity: Prettier check with the repo ignore list. (The pre-commit variant additionally fixes; the verdict form is this check.)
- **QC-5 Test suite execution.** Owns execution of all test surfaces: checkout script units (`node --test`), per-package Vitest suites (including the framework shell chain and its python unitests), the installer-lane shim test, and — once wired — `test-roster-schema.py` and container E2E. Also owns guards-of-the-gate that live inside the chain (terminal-green contract, RCE regression).
- **QC-6 Workspace build.** Owns artifact buildability: `turbo build` producing the artifacts publication consumes.
- **QC-7 Framework sanitization.** Owns the open-source guarantee for the shipped framework package: no operator-identity tokens anywhere (examples included), no private `$HOME` defaults in shipped scripts, with a self-test that keeps the regexes honest.
- **QC-8 Resident-context budget.** Owns the line-count ceilings on framework files injected into every agent's context (Constitution, dispatcher, RUNTIME.md slices) — the CI-enforceable half of the resident-prompt budget.
- **QC-9 Test-membership enumeration.** Owns the property that no test suite can silently fall out of CI: disk population vs parsed enumeration surfaces, both-directions staleness, and signed exclusions with reasons. Includes its needle/control harness.
- **QC-10 Upgrade/install safety.** Owns the #791 family: operator-path byte-identity across keep-mode upgrades (manifest guard), mid-failure rollback (errtrace-proven), durable pre-update snapshot + verify net + CWE-59 leaf guard, and the v2→v3 migration matrix with shell/TS parity.
- **QC-11 Terminal release verification.** Owns the RI-N1 exact-commit binding: commit-identity assertion plus one canonical command (`pnpm verify:release`) replaying the complete mandatory set, with every publish effect depending on it; plus the checkout-time parity/DAG contract test that keeps pipeline and command in sync.
- **QC-12 Publish-effect integrity.** Owns publication correctness: npm publish error classification (only already-published tolerated), next-lane versioning and post-publish resolution proof, and image destination/tag policy.
- **QC-13 Staged-change hygiene.** Owns commit-time hygiene on staged files (prettier/eslint fix-and-restage) and the self-verifying hook wiring that guarantees the gates are actually installed.
- **QC-14 Pre-push gate.** Owns the local push composition: preflight + typecheck + lint + format:check (tests deliberately deferred to CI).
- **QC-15 Lease-gate architecture invariant.** Owns "no ungated runtime launches in production code": the scan + allowlist over `packages/`, `apps/`, `plugins/`, `tools/`.
- **QC-16 Agent-runtime edit-time checks.** Owns edit-time feedback on agent hosts: the deps-preflight legibility sentinel and typecheck-on-edit, plus their regression harnesses.
- **QC-17 Lease-enforcement wiring safety.** Owns the #869 C1/C2/C5 trio: activation capability probe (versioned contract), enforcement-hook wiring gate (default-deny with explicit opt-out), and the doctor check that surfaces a bricked host — with their shell/TS harnesses.
- **QC-18 Operator-host drift audit.** Owns host-state health CI cannot see: `mosaic doctor` drift audit (+ fleet transport, both implementations), `fleet doctor` roster classification, `gateway doctor`/`gateway verify` service health, and installer advisories. Advisory exits are part of the contract.
- **QC-19 Downstream rails presence check.** Owns "does a scaffolded project still carry its rails files" — today the TS `quality-rails check/doctor` presence loop; per RI-N4 this is the seed that must become the typed evaluator (presence alone is explicitly not parity).
- **QC-20 Downstream enforcement verification.** Owns "do the rails actually block" on scaffolded projects: the behavioral planted-commit probe (type error, `any`, lint, gitleaks secret) currently in `verify.sh`/`verify.ps1` behind the `mosaic-quality-verify` adapter.
- **QC-21 Downstream rails scaffolding.** Owns putting rails files into a target project: the shell template installer (+ PowerShell twin) and the TS `quality-rails init` scaffolder — currently two paths that must converge.
## 3. Coverage gaps
Enforced nowhere but implied, or named in docs/tooling but not wired:
1. **Publication not yet bound to verification on `next`.** At this base (`8199261c`), `publish.yml` publish steps depend on `build` only; the `verify` step and `scripts/verify-release.mjs` exist on `feat/ri-050-publish-gate` (`46784c8d`) but are not merged. Until RI-1-001 lands, AC-RI-1's negative control cannot hold on the real pipeline.
2. **Playwright E2E unwired.** `apps/web` ships `test:e2e` (`playwright test`) with real suites (`admin/auth/chat/navigation.spec.ts`); neither `pnpm test` nor any CI step invokes it. The web UI's user flows are verified only when an operator runs them manually.
3. **No secret scanning on this repo.** The framework's own template pre-commit makes gitleaks **required**, and `verify.sh` proves detection with a planted key — but this repository's `.husky/pre-commit` (lint-staged only) and CI run no secret scan. The repo ships the control it does not use.
4. **No dependency audit.** The quality `.woodpecker.yml` templates and `docs/CI-SETUP.md` specify `npm audit --audit-level=high` as a pipeline stage; nothing equivalent runs for this repo.
5. **No coverage thresholds.** Templates enforce 80% Jest coverage thresholds; this repo's Vitest configs collect coverage with no thresholds — coverage is measured nowhere and enforced nowhere.
6. **`test-roster-schema.py` invisible.** A real jsonschema regression suite wired to no surface and invisible to the enumeration guard (its population is `*.sh`; the suite is `.py`). Either enumerate it or sign an exclusion — silence here is the #1017 defect shape.
7. **Presence-checker expectations ≠ this repo.** `quality-rails check` expects `.eslintrc`, `biome.json`, `.githooks/pre-commit`, `PR-CHECKLIST.md` for node projects — none describe this monorepo (husky, flat eslint config, no biome, no PR-CHECKLIST.md). The evaluator's check set must be per-subject (versioned, digested), not one global file list.
8. **Chain-ordering residual (documented).** `test:framework-shell` is one `&&` chain: a failing link skips every later suite while the step still fails (measured in #1270 — four suites after position 44 had not run since a prior merge). The enumeration guard proves naming, not reachability; both residuals are in-file documented but structurally unfixed.
9. **Signed-exclusion burndown open.** 16 signed exclusions remain in `test-enumeration-exclusions.txt`; several are "unmeasured in CI image" or blocked on missing CI tooling (tmux, setsid) — tracked under #1017/#1271. Each is an enforcement promise deferred, not delivered.
10. **Windows twins unexercised.** `verify.ps1`, `install.ps1`, `mosaic-doctor.ps1` have no runner anywhere (no Windows CI); behavioral drift from their bash twins is undetectable by construction.
11. **QA hook name vs behavior.** `qa-hook-handler.sh` files remediation report templates but performs no verification; the seam's actual gate value is only the deps-preflight sentinel. Anything relying on "QA automation hook" as a check is relying on report-filing.
12. **Two test paths, one gated.** CI runs tests against ci-postgres (`DATABASE_URL` set); the local PGlite path is the documented default (AGENTS.md) until KBN-101-02/101-05. Only the CI path is enforced by pipeline.
## 4. Disposition summary
| disposition | rows | checks |
| ------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| preserve | 43 | Every canonical owner (QC-1..QC-18) plus correct guards-of-the-guard and thin adapters: all of §1.1, the CI-invoked framework probes and adapters in §1.2, all of §1.3, the C1/C2/C5 trio and doctors in §1.4, all of §1.5, all pipeline-only steps in §1.6, §1.7 rows 1 and 3, and §1.8. |
| strengthen | 2 | `quality-rails check` and `quality-rails doctor` (QC-19) — the RI-N4 evaluator seed: typed verdicts, versioned/digested check definitions, per-subject check sets. |
| strengthen (review) | 9 | `verify.sh` + `verify.ps1` (QC-20), quality `install.sh`/`install.ps1` + `quality-rails init` (QC-21 — scaffold-path convergence), `test-roster-schema.py` (QC-5 — wire or sign), `qa-hook-stdin.sh` seam + `typecheck-hook.sh` (QC-16), `tools/e2e-install-test.sh` (QC-5 — CI lane). |
| retire | 0 | None meet the bar: RI-N4 requires effective shell probes be **absorbed before** their paths retire, and no absorption exists yet. The `strengthen (review)` rows are the retirement candidates for RI-3-002 once the evaluator owns their behavior. |
Row total: 54. Canonical checks: 21 (QC-1..QC-21).
+253
View File
@@ -0,0 +1,253 @@
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { normalizeGate, countAIFindings, runGate, runGates } from '../src/gate-runner.js';
function makeTmpDir(): string {
const dir = join(tmpdir(), `macp-gate-${randomUUID()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
describe('normalizeGate', () => {
it('normalizes a string to mechanical gate', () => {
expect(normalizeGate('echo test')).toEqual({
command: 'echo test',
type: 'mechanical',
fail_on: 'blocker',
});
});
it('normalizes an object gate with defaults', () => {
expect(normalizeGate({ command: 'lint' })).toEqual({
command: 'lint',
type: 'mechanical',
fail_on: 'blocker',
});
});
it('preserves explicit type and fail_on', () => {
expect(normalizeGate({ command: 'review', type: 'ai-review', fail_on: 'any' })).toEqual({
command: 'review',
type: 'ai-review',
fail_on: 'any',
});
});
it('handles non-string/non-object input', () => {
expect(normalizeGate(42)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
expect(normalizeGate(null)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
});
});
describe('countAIFindings', () => {
it('returns zeros for non-object', () => {
expect(countAIFindings(null)).toEqual({ blockers: 0, total: 0 });
expect(countAIFindings('string')).toEqual({ blockers: 0, total: 0 });
expect(countAIFindings([])).toEqual({ blockers: 0, total: 0 });
});
it('counts from stats block', () => {
const output = { stats: { blockers: 2, should_fix: 3, suggestions: 1 } };
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 6 });
});
it('counts from findings array when stats has no blockers', () => {
const output = {
stats: { blockers: 0 },
findings: [{ severity: 'blocker' }, { severity: 'warning' }, { severity: 'blocker' }],
};
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 3 });
});
it('uses stats blockers over findings array when stats has blockers', () => {
const output = {
stats: { blockers: 5 },
findings: [{ severity: 'blocker' }, { severity: 'warning' }],
};
// stats.blockers = 5, total from stats = 5+0+0 = 5, findings not used for total since stats total is non-zero
expect(countAIFindings(output)).toEqual({ blockers: 5, total: 5 });
});
it('counts findings length as total when stats has zero total', () => {
const output = {
findings: [{ severity: 'warning' }, { severity: 'info' }],
};
expect(countAIFindings(output)).toEqual({ blockers: 0, total: 2 });
});
});
describe('runGate', () => {
let tmp: string;
let logPath: string;
beforeEach(() => {
tmp = makeTmpDir();
logPath = join(tmp, 'gate.log');
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it('passes mechanical gate on exit 0', () => {
const result = runGate('echo hello', tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.exit_code).toBe(0);
expect(result.type).toBe('mechanical');
expect(result.output).toContain('hello');
});
it('fails mechanical gate on non-zero exit', () => {
const result = runGate('exit 1', tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.exit_code).toBe(1);
});
it('ci-pipeline always passes', () => {
const result = runGate({ command: 'anything', type: 'ci-pipeline' }, tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.type).toBe('ci-pipeline');
expect(result.output).toBe('CI pipeline gate placeholder');
});
it('empty command passes', () => {
const result = runGate({ command: '' }, tmp, logPath, 30);
expect(result.passed).toBe(true);
});
it('ai-review gate parses JSON output', () => {
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.blockers).toBe(0);
expect(result.findings).toBe(1);
});
it('ai-review gate fails on blockers', () => {
const json = JSON.stringify({ stats: { blockers: 2 } });
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.blockers).toBe(2);
});
it('ai-review gate with fail_on=any fails on any findings', () => {
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
const result = runGate(
{ command: `echo '${json}'`, type: 'ai-review', fail_on: 'any' },
tmp,
logPath,
30,
);
expect(result.passed).toBe(false);
expect(result.fail_on).toBe('any');
});
it('ai-review gate fails on invalid JSON output', () => {
const result = runGate({ command: 'echo "not json"', type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.parse_error).toBeDefined();
});
it('writes to log file', () => {
runGate('echo logged', tmp, logPath, 30);
const log = readFileSync(logPath, 'utf-8');
expect(log).toContain('COMMAND: echo logged');
expect(log).toContain('logged');
expect(log).toContain('EXIT:');
});
});
describe('runGates', () => {
let tmp: string;
let logPath: string;
let eventsPath: string;
beforeEach(() => {
tmp = makeTmpDir();
logPath = join(tmp, 'gates.log');
eventsPath = join(tmp, 'events.ndjson');
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it('runs multiple gates and returns results', () => {
const { allPassed, gateResults } = runGates(
['echo one', 'echo two'],
tmp,
logPath,
30,
eventsPath,
'task-1',
);
expect(allPassed).toBe(true);
expect(gateResults).toHaveLength(2);
});
it('reports failure when any gate fails', () => {
const { allPassed, gateResults } = runGates(
['echo ok', 'exit 1'],
tmp,
logPath,
30,
eventsPath,
'task-2',
);
expect(allPassed).toBe(false);
expect(gateResults[0]!.passed).toBe(true);
expect(gateResults[1]!.passed).toBe(false);
});
it('emits events for each gate', () => {
runGates(['echo test'], tmp, logPath, 30, eventsPath, 'task-3');
const events = readFileSync(eventsPath, 'utf-8')
.trim()
.split('\n')
.map((l) => JSON.parse(l));
expect(events).toHaveLength(2); // started + passed
expect(events[0].event_type).toBe('rail.check.started');
expect(events[1].event_type).toBe('rail.check.passed');
});
it('skips gates with empty command (non ci-pipeline)', () => {
const { gateResults } = runGates(
[{ command: '', type: 'mechanical' }, 'echo real'],
tmp,
logPath,
30,
eventsPath,
'task-4',
);
expect(gateResults).toHaveLength(1);
});
it('does not skip ci-pipeline even with empty command', () => {
const { gateResults } = runGates(
[{ command: '', type: 'ci-pipeline' }],
tmp,
logPath,
30,
eventsPath,
'task-5',
);
expect(gateResults).toHaveLength(1);
expect(gateResults[0]!.passed).toBe(true);
});
it('emits failed event with correct message', () => {
runGates(['exit 42'], tmp, logPath, 30, eventsPath, 'task-6');
const events = readFileSync(eventsPath, 'utf-8')
.trim()
.split('\n')
.map((l) => JSON.parse(l));
const failEvent = events.find(
(e: Record<string, unknown>) => e.event_type === 'rail.check.failed',
);
expect(failEvent).toBeDefined();
expect(failEvent.message).toContain('Gate failed (');
});
});
+1 -163
View File
@@ -1,8 +1,5 @@
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { describe, it, expect } from 'vitest';
import { Command } from 'commander';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { registerMacpCommand } from './cli.js';
describe('registerMacpCommand', () => {
@@ -78,162 +75,3 @@ describe('registerMacpCommand', () => {
expect(topLevel).toContain('events');
});
});
/**
* RI-N2 fail-closed CLI behavior: an unimplemented capability is a failure,
* never a success. Every stub exits nonzero with a typed message, and the
* implemented `macp gate` mirrors the typed gate-runner states.
*/
describe('registerMacpCommand fail-closed (RI-N2)', () => {
let tmpDir: string;
function buildProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({ writeErr: () => {} });
registerMacpCommand(program);
return program;
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'macp-cli-failclosed-'));
process.exitCode = 0;
});
afterEach(() => {
process.exitCode = 0;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('macp tasks list exits nonzero (unimplemented capability)', async () => {
const program = buildProgram();
await program.parseAsync(['macp', 'tasks', 'list'], { from: 'user' });
expect(process.exitCode).not.toBe(0);
});
it('macp submit exits nonzero with a typed MACP_NOT_IMPLEMENTED message', async () => {
const program = buildProgram();
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await program.parseAsync(['macp', 'submit', 'spec.json'], { from: 'user' });
expect(process.exitCode).not.toBe(0);
const errText = errSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(errText).toContain('MACP_NOT_IMPLEMENTED');
} finally {
errSpy.mockRestore();
}
});
it('macp events tail exits nonzero (unimplemented capability)', async () => {
const program = buildProgram();
await program.parseAsync(['macp', 'events', 'tail'], { from: 'user' });
expect(process.exitCode).not.toBe(0);
});
it('macp gate runs a green inline command and exits 0', async () => {
const program = buildProgram();
await program.parseAsync(
[
'macp',
'gate',
'exit 0',
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
expect(process.exitCode).toBe(0);
});
it('macp gate exits nonzero on a failing command', async () => {
const program = buildProgram();
await program.parseAsync(
[
'macp',
'gate',
'exit 9',
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
expect(process.exitCode).not.toBe(0);
});
it('macp gate with an unimplemented ci-pipeline capability exits nonzero', async () => {
const program = buildProgram();
const specPath = path.join(tmpDir, 'gates.json');
fs.writeFileSync(specPath, JSON.stringify([{ type: 'ci-pipeline' }]));
await program.parseAsync(
[
'macp',
'gate',
specPath,
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
expect(process.exitCode).not.toBe(0);
});
it('macp gate --simulate completes (exit 0) but reports simulated results', async () => {
const program = buildProgram();
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
await program.parseAsync(
[
'macp',
'gate',
'exit 0',
'--simulate',
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
// completes only because the caller explicitly asked to simulate
expect(process.exitCode).toBe(0);
const outText = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(outText).toContain('simulated');
expect(outText).toContain('SIMULATED');
} finally {
logSpy.mockRestore();
}
});
it('macp gate with an empty spec exits nonzero with a typed error', async () => {
const program = buildProgram();
await program.parseAsync(
[
'macp',
'gate',
' ',
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
expect(process.exitCode).not.toBe(0);
});
});
+19 -129
View File
@@ -1,73 +1,5 @@
import { existsSync, readFileSync } from 'node:fs';
import type { Command } from 'commander';
import { runGates } from './gate-runner.js';
import { MACPCapabilityError, type MacpErrorCode } from './errors.js';
/**
* Load gates from a spec: an existing file (JSON gates array, a JSON object
* with `quality_gates`, a JSON gate object, or one command per line) or an
* inline command string. Fails closed with a typed capability error when the
* spec contains no executable gate definition.
*/
function loadGateSpec(spec: string): unknown[] {
if (existsSync(spec)) {
const raw = readFileSync(spec, 'utf-8');
try {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
if (parsed.length === 0) {
throw new MACPCapabilityError(
'MACP_NO_COMMAND',
'gate-spec',
`gate spec file '${spec}' contains an empty gates array`,
);
}
return parsed;
}
if (typeof parsed === 'object' && parsed !== null) {
const obj = parsed as Record<string, unknown>;
if (Array.isArray(obj['quality_gates'])) {
return obj['quality_gates'];
}
return [parsed];
}
throw new MACPCapabilityError(
'MACP_NO_COMMAND',
'gate-spec',
`gate spec file '${spec}' parsed to ${typeof parsed} — expected a gates array, a task with quality_gates, or a gate object`,
);
} catch (exc) {
if (exc instanceof MACPCapabilityError) throw exc;
// Not JSON — treat each non-empty line as a command gate.
const lines = raw
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0);
if (lines.length > 0) return lines;
throw new MACPCapabilityError(
'MACP_NO_COMMAND',
'gate-spec',
`gate spec file '${spec}' contains no gates`,
);
}
}
if (spec.trim().length > 0) return [spec];
throw new MACPCapabilityError('MACP_NO_COMMAND', 'gate-spec', 'gate spec is empty');
}
/** Print a typed not-implemented failure and exit nonzero (RI-N2 fail-closed). */
function notImplemented(subcommand: string, capability: string, hint: string): void {
const err = new MACPCapabilityError(
'MACP_NOT_IMPLEMENTED',
capability,
`${subcommand} is not implemented in @mosaicstack/macp yet (${capability} capability absent) — ${hint}`,
);
console.error(`[macp] ${subcommand}: ${err.message} [${err.code}]`);
process.exitCode = 1;
}
/**
* Register macp subcommands on an existing Commander program.
* This avoids cross-package Commander version mismatches by using the
@@ -92,14 +24,15 @@ export function registerMacpCommand(parent: Command): void {
'Filter by task type (coding|deploy|research|review|documentation|infrastructure)',
)
.action((opts: { status?: string; type?: string }) => {
// unimplemented capability — a failure, never a success (RI-N2)
// not yet wired — task persistence layer is not present in @mosaicstack/macp
console.log('[macp] tasks list: not yet wired — use macp package programmatically');
if (opts.status) {
console.log(` status filter: ${opts.status}`);
}
if (opts.type) {
console.log(` type filter: ${opts.type}`);
}
notImplemented('tasks list', 'task-persistence', 'use the macp package programmatically');
process.exitCode = 0;
});
// ─── submit ──────────────────────────────────────────────────────────────
@@ -108,11 +41,12 @@ export function registerMacpCommand(parent: Command): void {
.command('submit <path>')
.description('Submit a task from a JSON/YAML spec file')
.action((specPath: string) => {
// unimplemented capability — a failure, never a success (RI-N2)
// not yet wired — task submission requires a running MACP server
console.log('[macp] submit: not yet wired — use macp package programmatically');
console.log(` spec path: ${specPath}`);
console.log(' task id: (unavailable — no MACP server connected)');
console.log(' status: (unavailable — no MACP server connected)');
notImplemented('submit', 'macp-server', 'use the macp package programmatically');
process.exitCode = 0;
});
// ─── gate ────────────────────────────────────────────────────────────────
@@ -124,58 +58,16 @@ export function registerMacpCommand(parent: Command): void {
.option('--cwd <path>', 'Working directory for gate execution', process.cwd())
.option('--log <path>', 'Path to write gate log output', '/tmp/macp-gate.log')
.option('--timeout <seconds>', 'Gate timeout in seconds', '60')
.option(
'--simulate',
'Simulate gates instead of executing them; results are typed simulated and never satisfy a check',
)
.action(
(
spec: string,
opts: { failOn: string; cwd: string; log: string; timeout: string; simulate?: boolean },
) => {
let gates: unknown[];
try {
gates = loadGateSpec(spec);
} catch (exc) {
if (exc instanceof MACPCapabilityError) {
console.error(`[macp] gate: ${exc.message} [${exc.code}]`);
} else {
console.error(`[macp] gate: ${String(exc)}`);
}
process.exitCode = 1;
return;
}
const timeoutSec = Number.parseInt(opts.timeout, 10) || 60;
const eventsPath = `${opts.log}.events.ndjson`;
const { state, gateResults } = runGates(
gates,
opts.cwd,
opts.log,
timeoutSec,
eventsPath,
'macp-cli-gate',
{
simulate: opts.simulate,
},
);
for (const r of gateResults) {
const label = r.command || r.type;
const reason = r.reason ? `${r.reason}` : '';
console.log(`[macp] gate ${r.status}: ${label}${reason}`);
}
if (opts.simulate) {
console.log(
'[macp] SIMULATED run — every result is typed simulated and can never satisfy a gate, dependency, or release check',
);
}
// Simulated runs may complete (exit 0) only because the caller
// explicitly passed --simulate; the typed state stays 'simulated'.
process.exitCode = state === 'passed' || state === 'simulated' ? 0 : 1;
},
);
.action((spec: string, opts: { failOn: string; cwd: string; log: string; timeout: string }) => {
// not yet wired — gate execution requires a task context and event sink
console.log('[macp] gate: not yet wired — use macp package programmatically');
console.log(` spec: ${spec}`);
console.log(` fail-on: ${opts.failOn}`);
console.log(` cwd: ${opts.cwd}`);
console.log(` log: ${opts.log}`);
console.log(` timeout: ${opts.timeout}s`);
process.exitCode = 0;
});
// ─── events ──────────────────────────────────────────────────────────────
@@ -187,16 +79,14 @@ export function registerMacpCommand(parent: Command): void {
.option('--file <path>', 'Path to the MACP events NDJSON file')
.option('--follow', 'Follow the file for new events (like tail -f)')
.action((opts: { file?: string; follow?: boolean }) => {
// unimplemented capability — a failure, never a success (RI-N2)
// not yet wired — event streaming requires a live event source
console.log('[macp] events tail: not yet wired — use macp package programmatically');
if (opts.file) {
console.log(` file: ${opts.file}`);
}
if (opts.follow) {
console.log(' mode: follow');
}
notImplemented('events tail', 'event-source', 'use the macp package programmatically');
process.exitCode = 0;
});
}
// Re-export so CLI consumers can surface typed capability codes.
export type { MacpErrorCode };
-35
View File
@@ -1,35 +0,0 @@
/** Typed error code from the closed MACP_ERROR_CODES set. */
export type MacpErrorCode = (typeof MACP_ERROR_CODES)[number];
/**
* Typed fail-closed capability errors (RI-N2, SDLC-D-035).
*
* MACP must fail closed when a required capability (executor, reviewer,
* command, CI provider, human authority) is absent. These typed codes mirror
* the Forge failure vocabulary (FORGE_NO_*) so both packages speak the same
* language: an unimplemented capability is a failure, never a stub success.
*/
/** Closed set of typed MACP capability error codes. */
export const MACP_ERROR_CODES = [
'MACP_NOT_IMPLEMENTED',
'MACP_NO_COMMAND',
'MACP_NO_REVIEWER',
'MACP_NO_CI_PIPELINE',
'MACP_NO_PROVIDER',
'MACP_AUTHORITY_REQUIRED',
] as const;
/** Raised when a required capability is missing and execution must fail closed. */
export class MACPCapabilityError extends Error {
/** Typed error code from the closed MACP_ERROR_CODES set. */
readonly code: MacpErrorCode;
/** The missing capability, e.g. `ci-provider`, `task-persistence`, `command`. */
readonly capability: string;
constructor(code: MacpErrorCode, capability: string, message: string) {
super(message);
this.name = 'MACPCapabilityError';
this.code = code;
this.capability = capability;
}
}
-429
View File
@@ -1,429 +0,0 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { countAIFindings, normalizeGate, runGate, runGates } from './gate-runner.js';
function makeTmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'macp-gate-'));
}
describe('normalizeGate', () => {
it('normalizes a string to mechanical gate', () => {
expect(normalizeGate('echo test')).toEqual({
command: 'echo test',
type: 'mechanical',
fail_on: 'blocker',
});
});
it('normalizes an object gate with defaults', () => {
expect(normalizeGate({ command: 'lint' })).toEqual({
command: 'lint',
type: 'mechanical',
fail_on: 'blocker',
});
});
it('preserves explicit type and fail_on', () => {
expect(normalizeGate({ command: 'review', type: 'ai-review', fail_on: 'any' })).toEqual({
command: 'review',
type: 'ai-review',
fail_on: 'any',
});
});
it('handles non-string/non-object input', () => {
expect(normalizeGate(42)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
expect(normalizeGate(null)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
});
});
describe('countAIFindings', () => {
it('returns zeros for non-object', () => {
expect(countAIFindings(null)).toEqual({ blockers: 0, total: 0 });
expect(countAIFindings('string')).toEqual({ blockers: 0, total: 0 });
expect(countAIFindings([])).toEqual({ blockers: 0, total: 0 });
});
it('counts from stats block', () => {
const output = { stats: { blockers: 2, should_fix: 3, suggestions: 1 } };
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 6 });
});
it('counts from findings array when stats has no blockers', () => {
const output = {
stats: { blockers: 0 },
findings: [{ severity: 'blocker' }, { severity: 'warning' }, { severity: 'blocker' }],
};
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 3 });
});
it('uses stats blockers over findings array when stats has blockers', () => {
const output = {
stats: { blockers: 5 },
findings: [{ severity: 'blocker' }, { severity: 'warning' }],
};
// stats.blockers = 5, total from stats = 5+0+0 = 5, findings not used for total since stats total is non-zero
expect(countAIFindings(output)).toEqual({ blockers: 5, total: 5 });
});
it('counts findings length as total when stats has zero total', () => {
const output = {
findings: [{ severity: 'warning' }, { severity: 'info' }],
};
expect(countAIFindings(output)).toEqual({ blockers: 0, total: 2 });
});
});
describe('runGate', () => {
let tmp: string;
let logPath: string;
beforeEach(() => {
tmp = makeTmpDir();
logPath = path.join(tmp, 'gate.log');
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it('passes mechanical gate on exit 0', () => {
const result = runGate('echo hello', tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.exit_code).toBe(0);
expect(result.type).toBe('mechanical');
expect(result.output).toContain('hello');
});
it('fails mechanical gate on non-zero exit', () => {
const result = runGate('exit 1', tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.exit_code).toBe(1);
});
it('ci-pipeline fails closed without a CI provider (no placeholder pass)', () => {
const result = runGate({ command: 'anything', type: 'ci-pipeline' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.status).toBe('capability_failure');
expect(result.capability_code).toBe('MACP_NO_CI_PIPELINE');
expect(result.type).toBe('ci-pipeline');
expect(result.output).not.toBe('CI pipeline gate placeholder');
});
it('empty command is a typed capability failure, never a pass', () => {
const result = runGate({ command: '' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.status).toBe('capability_failure');
expect(result.capability_code).toBe('MACP_NO_COMMAND');
});
it('ai-review gate parses JSON output', () => {
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.blockers).toBe(0);
expect(result.findings).toBe(1);
});
it('ai-review gate fails on blockers', () => {
const json = JSON.stringify({ stats: { blockers: 2 } });
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.blockers).toBe(2);
});
it('ai-review gate with fail_on=any fails on any findings', () => {
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
const result = runGate(
{ command: `echo '${json}'`, type: 'ai-review', fail_on: 'any' },
tmp,
logPath,
30,
);
expect(result.passed).toBe(false);
expect(result.fail_on).toBe('any');
});
it('ai-review gate fails on invalid JSON output', () => {
const result = runGate({ command: 'echo "not json"', type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.parse_error).toBeDefined();
});
it('writes to log file', () => {
runGate('echo logged', tmp, logPath, 30);
const log = fs.readFileSync(logPath, 'utf-8');
expect(log).toContain('COMMAND: echo logged');
expect(log).toContain('logged');
expect(log).toContain('EXIT:');
});
});
describe('runGates', () => {
let tmp: string;
let logPath: string;
let eventsPath: string;
beforeEach(() => {
tmp = makeTmpDir();
logPath = path.join(tmp, 'gates.log');
eventsPath = path.join(tmp, 'events.ndjson');
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it('runs multiple gates and returns results', () => {
const { allPassed, gateResults } = runGates(
['echo one', 'echo two'],
tmp,
logPath,
30,
eventsPath,
'task-1',
);
expect(allPassed).toBe(true);
expect(gateResults).toHaveLength(2);
});
it('reports failure when any gate fails', () => {
const { allPassed, gateResults } = runGates(
['echo ok', 'exit 1'],
tmp,
logPath,
30,
eventsPath,
'task-2',
);
expect(allPassed).toBe(false);
expect(gateResults[0]!.passed).toBe(true);
expect(gateResults[1]!.passed).toBe(false);
});
it('emits events for each gate', () => {
runGates(['echo test'], tmp, logPath, 30, eventsPath, 'task-3');
const events = fs
.readFileSync(eventsPath, 'utf-8')
.trim()
.split('\n')
.map((l) => JSON.parse(l));
expect(events).toHaveLength(2); // started + passed
expect(events[0].event_type).toBe('rail.check.started');
expect(events[1].event_type).toBe('rail.check.passed');
});
it('does not silently skip gates with empty command — they become capability failures', () => {
const { gateResults, allPassed, state } = runGates(
[{ command: '', type: 'mechanical' }, 'echo real'],
tmp,
logPath,
30,
eventsPath,
'task-4',
);
expect(gateResults).toHaveLength(2);
expect(gateResults[0]!.status).toBe('capability_failure');
expect(gateResults[1]!.status).toBe('passed');
expect(allPassed).toBe(false);
expect(state).toBe('capability_failure');
});
it('does not skip ci-pipeline even with empty command — typed capability failure', () => {
const { gateResults, allPassed, state } = runGates(
[{ command: '', type: 'ci-pipeline' }],
tmp,
logPath,
30,
eventsPath,
'task-5',
);
expect(gateResults).toHaveLength(1);
expect(gateResults[0]!.passed).toBe(false);
expect(gateResults[0]!.status).toBe('capability_failure');
expect(allPassed).toBe(false);
expect(state).toBe('capability_failure');
});
it('emits failed event with correct message', () => {
runGates(['exit 42'], tmp, logPath, 30, eventsPath, 'task-6');
const events = fs
.readFileSync(eventsPath, 'utf-8')
.trim()
.split('\n')
.map((l) => JSON.parse(l));
const failEvent = events.find(
(e: Record<string, unknown>) => e.event_type === 'rail.check.failed',
);
expect(failEvent).toBeDefined();
expect(failEvent.message).toContain('Gate failed (');
});
});
/**
* RI-N2 / SDLC-D-035 fail-closed controls for the MACP gate runner.
*
* Invariant under test: `passed: true` occurs ONLY when a gate really executed
* and really exited green (`status === 'passed'`). Absent capabilities,
* manual sign-offs, and simulated runs are typed distinctly and can never
* make the aggregate `passed`.
*/
describe('gate-runner fail-closed (RI-N2)', () => {
let tmpDir: string;
let logPath: string;
let eventsPath: string;
beforeEach(() => {
tmpDir = makeTmpDir();
logPath = path.join(tmpDir, 'gate.log');
eventsPath = path.join(tmpDir, 'events.ndjson');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function run(gates: unknown[], options?: { simulate?: boolean }) {
return runGates(gates, tmpDir, logPath, 10, eventsPath, 'spec-task', options);
}
// ─── positive controls ───────────────────────────────────────────────────
it('a really-executed green command gate still passes', () => {
const result = run([{ command: 'exit 0', type: 'mechanical' }]);
expect(result.gateResults[0]!.status).toBe('passed');
expect(result.gateResults[0]!.passed).toBe(true);
expect(result.allPassed).toBe(true);
expect(result.state).toBe('passed');
});
it('explicit simulate completes and types every result simulated', () => {
const result = run([{ command: 'exit 0', type: 'mechanical' }, 'echo hello'], {
simulate: true,
});
expect(result.gateResults).toHaveLength(2);
for (const gate of result.gateResults) {
expect(gate.status).toBe('simulated');
expect(gate.passed).toBe(false);
}
expect(result.state).toBe('simulated');
});
it('a really-executed red command gate fails with typed status failed', () => {
const result = run([{ command: 'exit 3', type: 'mechanical' }]);
expect(result.gateResults[0]!.status).toBe('failed');
expect(result.gateResults[0]!.passed).toBe(false);
expect(result.allPassed).toBe(false);
expect(result.state).toBe('failed');
});
// ─── negative controls — each asserts typed status AND aggregate not passed ──
it('an empty-command gate is a capability_failure, not skipped and not passed', () => {
const result = run([{ command: '', type: 'mechanical' }]);
// runGates must not silently skip it — it produces a typed result
expect(result.gateResults).toHaveLength(1);
const gate = result.gateResults[0]!;
expect(gate.status).toBe('capability_failure');
expect(gate.capability_code).toBe('MACP_NO_COMMAND');
expect(gate.passed).toBe(false);
// aggregate is not passed
expect(result.allPassed).toBe(false);
expect(result.state).toBe('capability_failure');
expect(result.state).not.toBe('passed');
});
it('a commandless ai-review gate is a typed MACP_NO_REVIEWER capability_failure', () => {
const result = run([{ command: '', type: 'ai-review' }]);
expect(result.gateResults[0]!.status).toBe('capability_failure');
expect(result.gateResults[0]!.capability_code).toBe('MACP_NO_REVIEWER');
expect(result.allPassed).toBe(false);
expect(result.state).not.toBe('passed');
});
it('a ci-pipeline gate without a provider implementation is a capability_failure, never a placeholder pass', () => {
const result = run([{ command: '', type: 'ci-pipeline' }]);
const gate = result.gateResults[0]!;
expect(gate.status).toBe('capability_failure');
expect(gate.capability_code).toBe('MACP_NO_CI_PIPELINE');
expect(gate.passed).toBe(false);
// the old false-success placeholder must be gone
expect(gate.output).not.toBe('CI pipeline gate placeholder');
expect(result.allPassed).toBe(false);
expect(result.state).not.toBe('passed');
});
it('a ci-pipeline gate fails closed even alongside an otherwise green run', () => {
const result = run(['exit 0', { type: 'ci-pipeline', command: 'fake-ci' }]);
expect(result.gateResults[1]!.status).toBe('capability_failure');
expect(result.gateResults[0]!.status).toBe('passed');
expect(result.allPassed).toBe(false);
expect(result.state).toBe('capability_failure');
});
it('a manual gate with no automation enters typed waiting — neither pass nor fail', () => {
const result = run([{ type: 'manual' }]);
const gate = result.gateResults[0]!;
expect(gate.status).toBe('waiting');
expect(gate.passed).toBe(false);
expect(gate.exit_code).toBe(0);
// aggregate is not passed while any gate is waiting
expect(result.allPassed).toBe(false);
expect(result.state).toBe('waiting');
expect(result.state).not.toBe('passed');
});
it('a simulated result can never make the aggregate passed', () => {
const result = run(['exit 0', 'exit 0'], { simulate: true });
expect(result.gateResults.every((g) => g.status === 'simulated')).toBe(true);
expect(result.allPassed).toBe(false);
expect(result.state).toBe('simulated');
expect(result.state).not.toBe('passed');
});
it('waiting dominates an otherwise green aggregate', () => {
const result = run(['exit 0', { type: 'manual' }]);
expect(result.allPassed).toBe(false);
expect(result.state).toBe('waiting');
});
});
describe('runGate fail-closed (RI-N2)', () => {
let tmpDir: string;
let logPath: string;
beforeEach(() => {
tmpDir = makeTmpDir();
logPath = path.join(tmpDir, 'gate.log');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('simulate: true returns a typed simulated result without executing', () => {
const result = runGate('this-command-does-not-exist-xyz', tmpDir, logPath, 10, {
simulate: true,
});
expect(result.status).toBe('simulated');
expect(result.passed).toBe(false);
expect(result.exit_code).toBe(0);
});
it('normal mode executes for real and types a green gate passed', () => {
const result = runGate('echo ok', tmpDir, logPath, 10);
expect(result.status).toBe('passed');
expect(result.passed).toBe(true);
expect(result.output).toContain('ok');
});
it('a bare string gate normalizes to mechanical and executes', () => {
const result = runGate('exit 7', tmpDir, logPath, 10);
expect(result.type).toBe('mechanical');
expect(result.status).toBe('failed');
expect(result.passed).toBe(false);
});
});
+25 -148
View File
@@ -4,20 +4,7 @@ import { dirname } from 'node:path';
import { emitEvent } from './event-emitter.js';
import { nowISO } from './event-emitter.js';
import type { GateResult, GateStatus, RunGatesResult } from './types.js';
/** Typed reason stamped on every simulated gate result. */
export const SIMULATED_GATE_REASON =
'simulated execution (explicit simulate opt-in): gate was not evaluated by a real implementation';
/** Options for gate execution (RI-N2 fail-closed / explicit simulation). */
export interface RunGateOptions {
/**
* Explicit caller opt-in to simulation. Simulated gates are NOT executed;
* every result is typed `simulated` and never satisfies anything.
*/
simulate?: boolean;
}
import type { GateResult } from './types.js';
export interface NormalizedGate {
command: string;
@@ -116,91 +103,36 @@ export function countAIFindings(parsedOutput: unknown): { blockers: number; tota
return { blockers, total };
}
function simulatedResult(gateEntry: NormalizedGate): GateResult {
return {
command: gateEntry.command,
exit_code: 0,
type: gateEntry.type,
output: SIMULATED_GATE_REASON,
timed_out: false,
passed: false,
status: 'simulated',
reason: SIMULATED_GATE_REASON,
};
}
function capabilityFailureResult(
gateEntry: NormalizedGate,
code: GateResult['capability_code'],
reason: string,
): GateResult {
return {
command: gateEntry.command,
exit_code: 1,
type: gateEntry.type,
output: '',
timed_out: false,
passed: false,
status: 'capability_failure',
capability_code: code,
reason,
};
}
function waitingResult(gateEntry: NormalizedGate, reason: string): GateResult {
return {
command: gateEntry.command,
exit_code: 0,
type: gateEntry.type,
output: '',
timed_out: false,
passed: false,
status: 'waiting',
capability_code: 'MACP_AUTHORITY_REQUIRED',
reason,
};
}
export function runGate(
gate: unknown,
cwd: string,
logPath: string,
timeoutSec: number,
options: RunGateOptions = {},
): GateResult {
const gateEntry = normalizeGate(gate);
const gateType = gateEntry.type;
const command = gateEntry.command;
// Explicit simulation only: never executes, typed simulated, never satisfying.
if (options.simulate) {
return simulatedResult(gateEntry);
}
// Fail closed: no CI provider implementation exists in @mosaicstack/macp,
// so a ci-pipeline gate is an absent capability — never a placeholder pass.
if (gateType === 'ci-pipeline') {
return capabilityFailureResult(
gateEntry,
'MACP_NO_CI_PIPELINE',
`ci-pipeline gate '${gateEntry.command || gateType}' has no CI provider implementation wired — refusing placeholder pass`,
);
return {
command,
exit_code: 0,
type: gateType,
output: 'CI pipeline gate placeholder',
timed_out: false,
passed: true,
};
}
if (!command) {
// A manual gate with no automation waits for human sign-off: not pass, not fail.
if (gateType === 'manual') {
return waitingResult(
gateEntry,
`manual gate has no automation — waiting for human sign-off (type: ${gateType})`,
);
}
// Any other commandless gate is an absent capability — never a vacuous pass.
return capabilityFailureResult(
gateEntry,
gateType === 'ai-review' ? 'MACP_NO_REVIEWER' : 'MACP_NO_COMMAND',
`gate of type '${gateType}' has no command to execute — refusing empty-command pass`,
);
return {
command: '',
exit_code: 0,
type: gateType,
output: '',
timed_out: false,
passed: true,
};
}
const { exitCode, output, timedOut } = runShell(command, cwd, logPath, timeoutSec);
@@ -211,12 +143,10 @@ export function runGate(
output,
timed_out: timedOut,
passed: false,
status: 'failed',
};
if (gateType !== 'ai-review') {
result.passed = exitCode === 0;
result.status = result.passed ? 'passed' : 'failed';
return result;
}
@@ -240,7 +170,6 @@ export function runGate(
} else {
result.passed = exitCode === 0 && blockers === 0 && !timedOut && parseError === undefined;
}
result.status = result.passed ? 'passed' : 'failed';
result.fail_on = failOn;
result.blockers = blockers;
@@ -262,19 +191,16 @@ export function runGates(
timeoutSec: number,
eventsPath: string,
taskId: string,
options: RunGateOptions = {},
): RunGatesResult {
): { allPassed: boolean; gateResults: GateResult[] } {
let allPassed = true;
const gateResults: GateResult[] = [];
let hasCapabilityFailure = false;
let hasSimulated = false;
let hasFailed = false;
let hasWaiting = false;
for (const gate of gates) {
const gateEntry = normalizeGate(gate);
const gateCmd = gateEntry.command;
if (!gateCmd && gateEntry.type !== 'ci-pipeline') continue;
const label = gateCmd || gateEntry.type;
// NOTE: no silent skip — every gate produces a typed result (RI-N2).
emitEvent(
eventsPath,
'rail.check.started',
@@ -283,10 +209,10 @@ export function runGates(
'quality-gate',
`Running gate: ${label}`,
);
const result = runGate(gate, cwd, logPath, timeoutSec, options);
const result = runGate(gate, cwd, logPath, timeoutSec);
gateResults.push(result);
if (result.status === 'passed') {
if (result.passed) {
emitEvent(
eventsPath,
'rail.check.passed',
@@ -298,46 +224,7 @@ export function runGates(
continue;
}
if (result.status === 'waiting') {
hasWaiting = true;
emitEvent(
eventsPath,
'rail.check.waiting',
taskId,
'gated',
'quality-gate',
`Gate waiting: ${label}${result.reason ?? 'manual gate awaits sign-off'}`,
);
continue;
}
if (result.status === 'simulated') {
hasSimulated = true;
emitEvent(
eventsPath,
'rail.check.simulated',
taskId,
'gated',
'quality-gate',
`Gate simulated (non-satisfying): ${label}`,
);
continue;
}
if (result.status === 'capability_failure') {
hasCapabilityFailure = true;
emitEvent(
eventsPath,
'rail.check.failed',
taskId,
'gated',
'quality-gate',
`Gate capability failure (${result.capability_code ?? 'MACP_NO_PROVIDER'}): ${label}${result.reason ?? 'required capability is absent'}`,
);
continue;
}
hasFailed = true;
allPassed = false;
let message: string;
if (result.timed_out) {
message = `Gate timed out after ${timeoutSec}s: ${label}`;
@@ -349,15 +236,5 @@ export function runGates(
emitEvent(eventsPath, 'rail.check.failed', taskId, 'gated', 'quality-gate', message);
}
const state: GateStatus = hasCapabilityFailure
? 'capability_failure'
: hasSimulated
? 'simulated'
: hasFailed
? 'failed'
: hasWaiting
? 'waiting'
: 'passed';
return { allPassed: state === 'passed', gateResults, state };
return { allPassed, gateResults };
}
+2 -16
View File
@@ -6,13 +6,11 @@ export type {
DependsOnPolicy,
GateType,
GateFailOn,
GateStatus,
GateEntry,
Task,
EventType,
MACPEvent,
GateResult,
RunGatesResult,
TaskResult,
ProviderMeta,
ProviderRegistry,
@@ -20,11 +18,6 @@ export type {
export { CredentialError } from './types.js';
// Typed fail-closed capability errors (RI-N2, SDLC-D-035)
export { MACP_ERROR_CODES, MACPCapabilityError } from './errors.js';
export type { MacpErrorCode } from './errors.js';
// Credential resolver
export {
DEFAULT_CREDENTIALS_DIR,
@@ -42,16 +35,9 @@ export {
export type { ResolveCredentialsOptions } from './credential-resolver.js';
// Gate runner
export {
normalizeGate,
runShell,
countAIFindings,
runGate,
runGates,
SIMULATED_GATE_REASON,
} from './gate-runner.js';
export { normalizeGate, runShell, countAIFindings, runGate, runGates } from './gate-runner.js';
export type { NormalizedGate, RunGateOptions } from './gate-runner.js';
export type { NormalizedGate } from './gate-runner.js';
// Risk-floor (agent reflection loop — diff review classifier)
export { evaluateRiskFloor, DEFAULT_RISK_THRESHOLD } from './risk-floor.js';
+2 -39
View File
@@ -1,5 +1,3 @@
import type { MacpErrorCode } from './errors.js';
/** Task status values. */
export type TaskStatus = 'pending' | 'running' | 'gated' | 'completed' | 'failed' | 'escalated';
@@ -19,17 +17,7 @@ export type DispatchMode = 'yolo' | 'acp' | 'exec';
export type DependsOnPolicy = 'all' | 'any' | 'all_terminal';
/** Quality gate type. */
export type GateType = 'mechanical' | 'ai-review' | 'ci-pipeline' | 'manual';
/**
* Typed execution state of a gate closed set (RI-N2, SDLC-D-035).
*
* Only `passed` means "really executed and green". `simulated` is produced
* exclusively under an explicit simulate opt-in and never satisfies anything.
* `capability_failure` means a required executor/provider/command was absent.
* `waiting` means a manual gate awaits human sign-off (neither pass nor fail).
*/
export type GateStatus = 'passed' | 'failed' | 'simulated' | 'waiting' | 'capability_failure';
export type GateType = 'mechanical' | 'ai-review' | 'ci-pipeline';
/** Gate fail_on mode. */
export type GateFailOn = 'blocker' | 'any';
@@ -79,9 +67,7 @@ export type EventType =
| 'task.retry.scheduled'
| 'rail.check.started'
| 'rail.check.passed'
| 'rail.check.failed'
| 'rail.check.waiting'
| 'rail.check.simulated';
| 'rail.check.failed';
/** Structured event record. */
export interface MACPEvent {
@@ -102,14 +88,7 @@ export interface GateResult {
type: string;
output: string;
timed_out: boolean;
/** Back-compat boolean view — true ONLY when `status === 'passed'`. */
passed: boolean;
/** Typed discriminator — the authoritative gate outcome (RI-N2). */
status: GateStatus;
/** Typed capability error code, set when `status === 'capability_failure'`. */
capability_code?: MacpErrorCode;
/** Why a non-executed state (simulated/waiting/capability_failure) was reached. */
reason?: string;
fail_on?: string;
blockers?: number;
findings?: number;
@@ -117,22 +96,6 @@ export interface GateResult {
parse_error?: string;
}
/**
* Aggregate outcome of `runGates` (RI-N2).
*
* `state` is the typed aggregate: it is `passed` only when every gate really
* executed green. A `simulated` result makes the aggregate `simulated` (never
* `passed`); a `waiting` manual gate keeps the aggregate `waiting`; a missing
* capability makes it `capability_failure`. `allPassed` is exactly
* `state === 'passed'`, so a simulated or waiting result can never satisfy a
* dependency, acceptance criterion, gate, merge, or release check.
*/
export interface RunGatesResult {
allPassed: boolean;
gateResults: GateResult[];
state: GateStatus;
}
/** Result from a completed task. */
export interface TaskResult {
task_id: string;
@@ -51,8 +51,6 @@ fix() { fix_count=$((fix_count + 1)); echo "[FIX] $*"; }
warn_count=0
warn() { warn_count=$((warn_count + 1)); echo "[WARN] $*"; }
note() { echo "[NOTE] $*"; return 0; }
pass() {
if [[ $VERBOSE -eq 1 ]]; then
echo "[OK] $*"
@@ -290,18 +288,6 @@ check_brain_home() {
brain="$(resolve_brain_home)"
if [[ "$brain" == "$MOSAIC_HOME" ]]; then
# Implicit-path greenfield case (#1288 comment 23133, fred's trace): nothing
# in product code creates ~/.mosaic/fleet/agents — the first fleet write
# resolves legacy (generated-env-boundary resolves before creating) and
# then manufactures the evidence that keeps the host legacy. On a host with
# ~/.mosaic but no fleet/agents, the three operator checks all agree and all
# point the wrong way; this doctor is the only one that can disagree, so it
# must say it — as a note, not a warn: nothing is broken yet.
if [[ "$(cd "$MOSAIC_HOME" 2>/dev/null && pwd -P)" == "$HOME/.config/mosaic" \
&& -d "$HOME/.mosaic" && ! -d "$HOME/.mosaic/fleet/agents" ]]; then
note "Fleet state home: $MOSAIC_HOME (legacy). NOTE: ~/.mosaic exists but carries no fleet/agents — the first 'mosaic fleet regen' on this host locks in the legacy tree. Create ~/.mosaic/fleet/agents first to adopt the brain."
return
fi
pass "Fleet state home: $MOSAIC_HOME (legacy single-tree; no brain adopted)"
return
fi
@@ -39,26 +39,22 @@ eval "$(extract_function check_brain_home)"
ROOT=$(mktemp -d)
trap 'rm -rf "$ROOT"' EXIT
# note output is neither [OK] nor [WARN] — assert it directly in the case below.
run_case() {
# label, expect (ok|warn|note), then env assignments as arguments.
# label, expect (ok|warn), then env assignments as arguments.
# The check runs under `env` in a subshell, so its warn() also prints a
# sentinel the parent counts — a subshell counter would never be visible.
local label="$1" expect="$2"
shift 2
local out warns notes
out=$(env "$@" bash -c "warn() { echo \"[WARN] \$*\"; }; note() { echo \"[NOTE] \$*\"; return 0; }; pass() { echo \"[OK] \$*\"; return 0; }; $(extract_function resolve_brain_home); $(extract_function check_brain_home); check_brain_home" 2>&1)
local out warns
out=$(env "$@" bash -c "warn() { echo \"[WARN] \$*\"; }; pass() { echo \"[OK] \$*\"; return 0; }; $(extract_function resolve_brain_home); $(extract_function check_brain_home); check_brain_home" 2>&1)
warns=$(printf '%s\n' "$out" | grep -c '^\[WARN\]' || true)
notes=$(printf '%s\n' "$out" | grep -c '^\[NOTE\]' || true)
if [[ "$expect" == ok && "$warns" -eq 0 && "$notes" -eq 0 ]]; then
if [[ "$expect" == ok && "$warns" -eq 0 ]]; then
echo "ok - $label"
elif [[ "$expect" == warn && "$warns" -gt 0 ]]; then
echo "ok - $label (warned)"
elif [[ "$expect" == note && "$notes" -gt 0 ]]; then
echo "ok - $label (noted)"
else
echo "output: $out" >&2
fail "$label: expected $expect (warns=$warns notes=$notes)"
fail "$label: expected $expect (warns=$warns)"
fi
}
@@ -109,19 +105,4 @@ chmod 700 "$ROOT/brain-clean/fleet/agents" "$ROOT/config6/fleet/agents"
run_case "empty config-home agents dir alongside brain passes" ok \
MOSAIC_HOME="$ROOT/config6" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-clean"
# ── greenfield brain-without-agents at the default home → note (#1288) ─────
mkdir -p "$ROOT/gf-home/.config/mosaic/fleet" "$ROOT/gf-home/.mosaic"
run_case "~/.mosaic without fleet/agents at default home notes the lock-in" note \
MOSAIC_HOME="$ROOT/gf-home/.config/mosaic" HOME="$ROOT/gf-home"
# ── no ~/.mosaic at all at the default home → clean pass ─────────────────
mkdir -p "$ROOT/plain-home/.config/mosaic/fleet"
run_case "no ~/.mosaic at default home passes silently" ok \
MOSAIC_HOME="$ROOT/plain-home/.config/mosaic" HOME="$ROOT/plain-home"
# ── custom (non-default) home with a stray ~/.mosaic → still silent ──────
mkdir -p "$ROOT/custom-home/fleet/agents" "$ROOT/custom-home/.mosaic"
run_case "custom home with stray ~/.mosaic stays silent" ok \
MOSAIC_HOME="$ROOT/custom-home" HOME="$ROOT/custom-home"
echo "ok - mosaic-doctor brain-home check"
+7 -49
View File
@@ -43,7 +43,7 @@ fleet commits, pushes, and opens PRs under one identity — with no cryptographi
separation between an author and a reviewer.
Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent
identity**:
identity** before falling back to the shared account:
1. `MOSAIC_GIT_IDENTITY` environment variable, or
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
@@ -51,54 +51,12 @@ identity**:
3. (git-credential-mosaic only) the username git itself supplies for the credential
request.
### Which store a credential is read from
The store is chosen by what the identity **is**, not by which file happens to exist first:
| The identity | Its credential is read from |
| ------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| has a directory at `<brain>/fleet/agents/<id>/` — it is a **seat** | `<brain>/fleet/agents/<id>/secrets/gitea-{usc,mosaicstack}-<id>.token` |
| does not — it is a **service identity** | `~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-<id>.token` |
`<brain>` is `MOSAIC_BRAIN_HOME` if set, else `~/.mosaic` — the same resolution
`packages/mosaic/src/fleet/brain-home.ts` performs.
**There is no precedence between the two stores and no fallback from one to the other.**
A seat whose slot is empty is refused even when a same-named token sits in the framework
store. One credential lives in exactly one location: a second copy is drift rather than
redundancy, and the way drift surfaces is a stale copy returning 401, which reads as a
revoked token and sends whoever debugs it to the wrong place.
### What happens when nothing resolves
| identity resolves | token in its store | host runs a fleet | result |
| ----------------- | ------------------ | ----------------- | ------------------------- |
| yes | yes | — | that identity + token |
| yes | no | — | **fail closed** |
| no | — | yes | **fail closed** |
| no | — | no | shared account, unchanged |
A host "runs a fleet" when `<brain>/fleet/agents` exists — the same signal `brain-home.ts`
uses to decide a brain is active.
Failing closed means: nothing is emitted, the exit status is nonzero, a stderr diagnostic
names the identity, its source, the store it resolved to and the path that was expected,
and `git-credential-mosaic` additionally appends a record (identity, host, reason, cwd —
never a token value) to `${MOSAIC_CREDENTIAL_SPOOL:-~/.local/state/mosaic-credential-escalations}`.
The git operation fails; nothing is attributed to anyone.
The shared-account fallback that used to cover these two cases is why a PR could be
authored, commented and merged under an account whose owner did not open it — every seat
shared one identity, so the record could not be traced back afterwards. An
under-provisioned agent is refused rather than handed the most privileged account
available.
**On a host with no fleet, nothing changes**: no `fleet/agents` directory means the shared
account still answers, so this is a no-op for an operator who has not provisioned per-slot
tokens. On a host that does run a fleet, a human doing manual git work needs an identity
of their own — `MOSAIC_GIT_IDENTITY=<id>` with a provisioned slot. There is deliberately no
environment variable that restores the fallback; one would reintroduce exactly the
substitution this removes.
If the resolved identity has a token file at
`~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-<agent-id>.token`, that
identity + token is used. **Nothing configured → nothing changes**: with no per-slot
token file present, both tools fall through to the existing shared-account path
unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot
tokens.
### Enabling it for a clone
@@ -507,69 +507,37 @@ get_gitea_token() {
# 0. Per-agent identity (Gate-16 author≠reviewer). If MOSAIC_GIT_IDENTITY, or the
# per-worktree `git config mosaic.gitIdentity`, resolves to an agent that has a
# stored credential for this host, act AS that agent so API tooling
# stored per-slot token for this host, act AS that agent so API tooling
# (pr-create, issue-create, …) authors under the right identity — matching the
# git credential helper, which this block deliberately mirrors.
# git credential helper. Backward-compatible: nothing resolvable → shared logic below.
local _ident="${MOSAIC_GIT_IDENTITY:-}"
local _ident_src="MOSAIC_GIT_IDENTITY"
if [[ -z "$_ident" ]]; then
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
_ident_src="git config mosaic.gitIdentity"
fi
# Recognized Gitea hosts are the ones carrying the per-identity token scheme.
local _idpfx=""
case "$host" in
git.uscllc.com) _idpfx=gitea-usc ;;
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
esac
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
local _brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
if [[ -n "$_ident" && -n "$_idpfx" ]]; then
# Credential store selection: an identity is a SEAT or a SERVICE, and which
# one it is decides where its credential lives. No precedence between the
# two stores and no fallback from one to the other — a seat with an empty
# slot fails loud rather than reading a service credential of the same name.
# One credential, one location: two copies diverge, and the stale copy fails
# in a way that reads as a revoked token rather than as drift.
local _idtok _ident_kind
if [[ -d "$_brain_home/fleet/agents/$_ident" ]]; then
_ident_kind="seat"
_idtok="$_brain_home/fleet/agents/$_ident/secrets/${_idpfx}-${_ident}.token"
else
_ident_kind="service identity"
_idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
if [[ -n "$_ident" ]]; then
local _idpfx=""
case "$host" in
git.uscllc.com) _idpfx=gitea-usc ;;
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
esac
if [[ -n "$_idpfx" ]]; then
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
if [[ -r "$_idtok" ]]; then
cat "$_idtok"
return 0
fi
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
# but no per-slot token exists for THAT identity. Refuse to fall through to the
# shared/default credential loader below — silently borrowing another slot's token
# would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review attributed
# to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop instead so the
# caller aborts loudly rather than acting as the wrong identity.
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
echo " Refusing to borrow another slot's token. Provision the per-slot token, or unset the identity to use shared credentials." >&2
return 1
fi
if [[ -r "$_idtok" ]]; then
cat "$_idtok"
return 0
fi
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
# but no credential exists for THAT identity. Refuse to fall through to the
# shared/default credential loader below — silently borrowing another identity's
# token would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review
# attributed to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop
# instead so the caller aborts loudly rather than acting as the wrong identity.
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
echo " Resolved as a ${_ident_kind}; there is no fallback between the seat and service stores." >&2
echo " Refusing to borrow another slot's token. Provision the credential at that path, or unset the identity to use shared credentials." >&2
return 1
fi
# FAIL LOUD: no identity resolved, on a host that HAS a fleet. Where seats exist,
# every record must name the agent that made it, so an unattributable request is
# refused rather than handed the shared account. `fleet/agents` existing is the
# same signal brain-home.ts uses to decide a brain is active. A host with no fleet
# keeps the shared path below unchanged: there the shared account is the operator's
# own and there is no attribution to lose.
if [[ -z "$_ident" && -n "$_idpfx" && -d "$_brain_home/fleet/agents" ]]; then
echo "Error: no git identity resolved for host '$host', but this host runs a fleet ($_brain_home/fleet/agents)." >&2
echo " Refusing to fall back to the shared account: records it creates cannot be attributed to the agent that made them." >&2
echo " Set MOSAIC_GIT_IDENTITY=<agent-id> or 'git config mosaic.gitIdentity <agent-id>'." >&2
return 1
fi
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
@@ -1,48 +1,21 @@
#!/bin/bash
# git-credential-mosaic — git credential helper. Resolves a Gitea token from the
# Mosaic credential store at runtime so remote URLs never embed secrets.
# git-credential-mosaic — git credential helper — resolves Gitea tokens from
# the Mosaic credential store at runtime so remote URLs never embed secrets.
#
# Install (one-time, per clone or globally):
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
# # or, fleet-wide: git config --global credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
#
# Per-agent identity (Gate-16 author != reviewer separation):
# Per-agent Gate-16 identity (author != reviewer separation):
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
#
# ── WHY THIS FAILS CLOSED ──────────────────────────────────────────────────────
# This helper used to end by emitting the shared account's token for any request
# it could not resolve to an identity. A seat with no identity, or with an
# identity whose token was never provisioned, therefore received the most
# privileged credential configured on the host — silently, and indistinguishably
# from correct operation. Every record it then created (commit, push, PR, review)
# was attributed to that shared account, so author != reviewer separation was
# unenforceable and the true actor was unrecoverable after the fact.
#
# Under-provisioning must fail loudly, not impersonate. A refused git operation
# is recoverable in one command; a merged pull request attributed to the wrong
# principal is not.
#
# ── CONTRACT ───────────────────────────────────────────────────────────────────
# identity : MOSAIC_GIT_IDENTITY > git config mosaic.gitIdentity > the
# username git supplies on stdin
# store : chosen by what the identity IS, with no precedence and no
# cross-store fallback (see "Credential store selection" below)
# hit : emit username + password, exit 0
# miss : emit NOTHING, spool a durable escalation record, explain on
# stderr, exit 1 — git surfaces the failure and nothing is attributed
# unknown host : exit 0 with no output, no record (passthrough for non-Mosaic
# remotes handled by another helper)
#
# Backward compatibility is preserved for exactly one case: a host with no fleet
# and no identity requested still gets the shared account, because on such a host
# the shared account is the operator's own and there is no attribution to lose.
# A host that HAS a fleet has agents whose records must be distinguishable, so
# the shared fallback is refused there.
#
# A token is never written to stderr, to the escalation record, or to any log.
# Resolution priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity
# (per-worktree, survives across non-persistent shells) > git-supplied username
# (credential.username / URL). When the resolved identity has a matching
# per-agent token file, use it instead of the shared account. Backward
# compatible: nothing resolvable -> shared token (unchanged behavior).
[ "$1" = "get" ] || exit 0
host=""; username_in=""
while IFS= read -r line; do
[ -z "$line" ] && break
@@ -51,170 +24,46 @@ while IFS= read -r line; do
username=*) username_in=${line#username=};;
esac
done
# Recognized Gitea hosts carry the per-identity token scheme. Anything else is
# declined quietly — another helper owns it, and refusing would break it.
# Per-agent identity resolution (Gate-16 author≠reviewer separation).
# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree,
# survives across non-persistent shells) > git-supplied username (credential.username
# / URL). When the resolved identity has a matching per-agent token, use it instead of
# the shared account. Backward-compatible: nothing resolvable → shared token.
ident="$MOSAIC_GIT_IDENTITY"
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
[ -z "$ident" ] && ident="$username_in"
if [ -n "$ident" ]; then
case "$host" in
git.uscllc.com) idpfx=gitea-usc;;
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
*) idpfx="";;
esac
if [ -n "$idpfx" ]; then
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
if [ -r "$idtok" ]; then
echo "username=${ident}"
echo "password=$(cat "$idtok")"
exit 0
fi
fi
fi
case "$host" in
git.uscllc.com) idpfx=gitea-usc;;
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
git.uscllc.com) svc=gitea-usc;;
git.mosaicstack.dev) svc=gitea-mosaicstack;;
*) exit 0;;
esac
ident="$MOSAIC_GIT_IDENTITY"; ident_src="MOSAIC_GIT_IDENTITY"
if [ -z "$ident" ]; then
ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
ident_src="git config mosaic.gitIdentity"
fi
if [ -z "$ident" ]; then
ident="$username_in"
ident_src="the username git supplied"
fi
# ── Credential store selection ────────────────────────────────────────────────
# An identity is a SEAT or it is a SERVICE, and which one it is determines where
# its credential lives. There is no precedence rule between the two stores and no
# fallback from one to the other: a seat whose slot is empty fails closed rather
# than reading a service credential that happens to share its name.
#
# seat — <brain>/fleet/agents/<ident>/ exists
# credential at <brain>/fleet/agents/<ident>/secrets/<idpfx>-<ident>.token
# service — it does not
# credential at ~/.config/mosaic/secrets/gitea-tokens/<idpfx>-<ident>.token
#
# One credential, one location. Two copies of one credential diverge, and the
# stale copy fails in a way that reads as a revoked token rather than as drift.
#
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
svc_store="$HOME/.config/mosaic/secrets/gitea-tokens"
idtok=""; ident_kind=""
if [ -n "$ident" ]; then
if [ -d "$brain_home/fleet/agents/$ident" ]; then
ident_kind="seat"
idtok="$brain_home/fleet/agents/$ident/secrets/${idpfx}-${ident}.token"
else
ident_kind="service identity"
idtok="$svc_store/${idpfx}-${ident}.token"
fi
if [ -r "$idtok" ]; then
echo "username=${ident}"
echo "password=$(cat "$idtok")"
exit 0
fi
fi
# ── Shared-account fallback: ONLY on a host with no fleet and no identity ──────
# `fleet/agents` existing is the same signal brain-home.ts uses to decide a brain
# is active. Where there are seats, records must be attributable, so an
# unresolvable request is refused instead of borrowing the shared account.
fleet_present=0
[ -d "$brain_home/fleet/agents" ] && fleet_present=1
if [ -z "$ident" ] && [ "$fleet_present" -eq 0 ]; then
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../_lib/credentials.sh
source "$script_dir/../_lib/credentials.sh"
load_credentials "$idpfx" >/dev/null 2>&1 || exit 0
# GITEA_USER is not populated by load_credentials (it exports GITEA_URL and
# GITEA_TOKEN only). Gitea's git-over-HTTP auth authenticates from the token in
# the password field, not from the username string, so any non-empty
# placeholder works — deliberately NOT a real account name, since framework
# files stay operator-agnostic (tools/quality/scripts/verify-sanitized.sh).
echo "username=${GITEA_USER:-git}"
echo "password=$GITEA_TOKEN"
exit 0
fi
# ── FAIL CLOSED ───────────────────────────────────────────────────────────────
if [ -z "$ident" ]; then
reason="no-identity"
else
reason="no-token-for-identity"
fi
seat="${MOSAIC_AGENT_NAME:-unknown}"
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# The escalation RECORD is durable and unconditional; any notification built on
# top of it is best-effort. Record and alert are deduplicated separately — a cap
# on the alert alone lets the spool grow without bound exactly while the operator
# is being told nothing, so the louder the failure the quieter it gets.
#
# A record field is arbitrary operator-supplied text: an identity comes from git
# config or the environment, and cwd is whatever directory git ran in. Either can
# contain a quote or a backslash, which would make the line unparseable JSON --
# and a spool that silently stops parsing is worse than no spool, because the
# operator only discovers it while reading the record that explains an outage.
json_escape() {
local s=$1
s=${s//\\/\\\\}
s=${s//\"/\\\"}
s=${s//$'\t'/\\t}
s=${s//$'\r'/\\r}
s=${s//$'\n'/\\n}
printf '%s' "$s"
}
spool="${MOSAIC_CREDENTIAL_SPOOL:-$HOME/.local/state/mosaic-credential-escalations}"
spool_record=""
if mkdir -p "$spool" 2>/dev/null; then
chmod 700 "$spool" 2>/dev/null
spoolfile="$spool/$(date -u +%Y%m%d).jsonl"
dedupe="$spool/.spooled-${seat}-${ident:-none}-${reason}-$(date -u +%Y%m%d%H%M)"
if [ ! -e "$dedupe" ]; then
: > "$dedupe" 2>/dev/null
printf '{"ts":"%s","reason":"%s","identity":"%s","identity_source":"%s","kind":"%s","seat":"%s","host":"%s","cwd":"%s"}\n' \
"$(json_escape "$ts")" "$(json_escape "$reason")" \
"$(json_escape "${ident:-<unset>}")" "$(json_escape "$ident_src")" \
"$(json_escape "${ident_kind:-none}")" "$(json_escape "$seat")" \
"$(json_escape "$host")" "$(json_escape "$PWD")" \
>> "$spoolfile" 2>/dev/null
chmod 600 "$spoolfile" 2>/dev/null
fi
# Name the record only if one is actually on disk. Printing the path
# unconditionally sends the operator to a file that does not exist on exactly
# the hosts where the spool could not be created.
[ -s "$spoolfile" ] && spool_record="$spoolfile"
find "$spool" -maxdepth 1 -name '.spooled-*' -mmin +120 -delete 2>/dev/null
fi
cat >&2 <<EOF
git-credential-mosaic: REFUSED (fail-closed).
host : ${host}
identity : ${ident:-<unset>}${ident:+ (from ${ident_src}; resolved as a ${ident_kind})}
reason : ${reason}
EOF
if [ -n "$ident" ]; then
cat >&2 <<EOF
expected : ${idtok}
EOF
fi
cat >&2 <<EOF
No per-identity credential resolved. This helper does NOT fall back to the shared
account: that fallback makes every record it creates attributable to one
principal, which is unrecoverable once a pull request has merged under it.
Fix (pick one):
export MOSAIC_GIT_IDENTITY=<agent-id> # process-scoped
git config mosaic.gitIdentity <agent-id> # per-repo/worktree, persists
Then provision that identity's credential at the path named above. An identity
with a directory under \${MOSAIC_BRAIN_HOME:-\$HOME/.mosaic}/fleet/agents/ is a
seat and is read ONLY from its own secrets/ slot; any other identity is read from
~/.config/mosaic/secrets/gitea-tokens/. There is no fallback between the two.
If this identity legitimately needs git access and has none, ask the orchestrator
to provision one.
EOF
if [ -n "$spool_record" ]; then
echo " record: ${spool_record}" >&2
else
echo " record: NOT WRITTEN — spool unavailable at ${spool}" >&2
fi
exit 1
# Script-relative (not $HOME-absolute) so this resolves correctly regardless
# of where the framework installer places tools/ under $HOME — mirrors
# detect-platform.sh's own cred_loader resolution in this same directory.
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../_lib/credentials.sh
source "$script_dir/../_lib/credentials.sh"
load_credentials "$svc" >/dev/null 2>&1 || exit 0
# GITEA_USER is not populated by load_credentials (it only exports
# GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's
# git-over-HTTP auth authenticates from the token itself (the password field),
# not from the username string, so any non-empty placeholder works here — this
# is deliberately NOT a real account name (framework files must stay
# operator-agnostic; see tools/quality/scripts/verify-sanitized.sh).
echo "username=${GITEA_USER:-git}"
echo "password=$GITEA_TOKEN"
@@ -1,26 +1,16 @@
#!/usr/bin/env bash
# Regression harness for `git-credential-mosaic` — per-agent Gitea identity
# resolution (Gate-16 author≠reviewer separation) and fail-closed refusal.
# resolution (Gate-16 author≠reviewer separation).
#
# Covers:
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
# mosaic.gitIdentity (per-worktree) > git-supplied username.
# 2. Correct token file path chosen per host
# 2. Correct per-slot token file path chosen per host
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
# 3. Credential store selection: an identity with a directory under
# <brain>/fleet/agents/ is a SEAT and is read ONLY from its own secrets/
# slot; any other identity is a SERVICE and is read from the framework
# store. No precedence between them and NO fallback from one to the other.
# 4. Fail-closed: an identity that resolves but has no credential is REFUSED —
# no output, nonzero exit, a stderr diagnostic, and a durable spool record.
# The shared account is never emitted in its place.
# 5. Fail-closed: no identity resolvable on a host that runs a fleet is also
# REFUSED, because records made there must name the agent that made them.
# 6. Backward compatibility, the one surviving fallback: no identity AND no
# fleet -> shared account, unchanged. On such a host the shared account is
# the operator's own and there is no attribution to lose.
# 7. Unknown/unrelated host -> exits 0 with no output (passthrough).
# 8. Non-"get" verb -> exits 0 with no output.
# 3. Per-slot token present -> emits that identity + token.
# 4. Per-slot token absent -> falls back to the shared account
# (backward-compat / no-op for hosts without per-slot tokens).
# 5. Unknown/unrelated host -> exits 0 with no output (passthrough).
#
# Uses stubbed token files under a fake HOME + a real (throwaway) git repo.
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
@@ -31,9 +21,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
FAKE_HOME="$WORK_DIR/home"
REPO_DIR="$WORK_DIR/repo"
BRAIN_DIR="$WORK_DIR/brain"
SPOOL_DIR="$WORK_DIR/spool"
SVC_STORE="$FAKE_HOME/.config/mosaic/secrets/gitea-tokens"
# Mirror the real deployed layout (~/.config/mosaic/tools/{git,_lib}/) under the
# fake HOME: git-credential-mosaic resolves its credentials.sh sibling via a
# script-relative path (BASH_SOURCE), so the copy must live next to a stubbed
@@ -41,10 +28,10 @@ SVC_STORE="$FAKE_HOME/.config/mosaic/secrets/gitea-tokens"
HELPER="$FAKE_HOME/.config/mosaic/tools/git/git-credential-mosaic"
rm -rf "$WORK_DIR"
mkdir -p "$SVC_STORE" \
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \
"$FAKE_HOME/.config/mosaic/tools/git" \
"$FAKE_HOME/.config/mosaic/tools/_lib" \
"$REPO_DIR" "$BRAIN_DIR"
"$REPO_DIR"
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
chmod +x "$HELPER"
@@ -81,8 +68,7 @@ run_helper() {
local host="$1" username_in="$2"; shift 2
(
cd "$REPO_DIR"
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" "$@" \
bash "$HELPER" get <<EOF
env -i HOME="$FAKE_HOME" PATH="$PATH" "$@" bash "$HELPER" get <<EOF
host=$host
username=$username_in
@@ -90,61 +76,20 @@ EOF
)
}
# A refusal must be observable in four independent ways: nonzero exit, EMPTY
# stdout, a stderr diagnostic naming the identity and host, and — the assertion
# that actually catches a regression to the old behavior — NO shared token value
# anywhere in the output. Checking only the exit code would pass against a helper
# that emitted the shared credential and then exited 1.
assert_fail_closed() {
local desc="$1" host="$2" username_in="$3" want_in_stderr="$4"; shift 4
local stderr_file="$WORK_DIR/stderr.tmp"
: > "$stderr_file"
set +e
local stdout
stdout=$(run_helper "$host" "$username_in" "$@" 2>"$stderr_file")
local rc=$?
set -e
local stderr
stderr=$(cat "$stderr_file")
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: $desc — expected nonzero exit, got 0 (stdout='$stdout')" >&2
fail=1
fi
if [[ -n "$stdout" ]]; then
echo "FAIL: $desc — expected empty stdout (nothing emitted), got '$stdout'" >&2
fail=1
fi
if [[ "$stdout$stderr" == *"shared-mosaicstack-token"* || "$stdout$stderr" == *"shared-usc-token"* ]]; then
echo "FAIL: $desc — a SHARED token value appeared in the output. The shared-account fallback must be gone:" >&2
echo "$stdout$stderr" >&2
fail=1
fi
if [[ -n "$want_in_stderr" && "$stderr" != *"$want_in_stderr"* ]]; then
echo "FAIL: $desc — stderr does not contain '$want_in_stderr':" >&2
echo "$stderr" >&2
fail=1
fi
if [[ "$stderr" != *"$host"* ]]; then
echo "FAIL: $desc — stderr does not name the host '$host':" >&2
echo "$stderr" >&2
fail=1
fi
}
# ---------------------------------------------------------------------------
# 1. Backward compatibility: nothing resolvable, and NO fleet on this host ->
# shared account, unchanged. This is the only surviving fallback.
# 1. No identity resolvable anywhere, no per-slot token -> shared fallback
# (backward-compat: unchanged behavior when nothing is configured).
# ---------------------------------------------------------------------------
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
out=$(run_helper "git.mosaicstack.dev" "")
assert_eq "no identity + no fleet: username" "username=git" "$(echo "$out" | grep '^username=')"
assert_eq "no identity + no fleet: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
# ---------------------------------------------------------------------------
# 2. git-supplied username resolves to a SERVICE identity WITH a token in the
# framework store -> that identity + token wins over the shared account.
# 2. git-supplied username resolves to an identity WITH a per-slot token ->
# that identity + token wins over the shared account.
# ---------------------------------------------------------------------------
echo -n "agentA-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentA.token"
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
out=$(run_helper "git.mosaicstack.dev" "agentA")
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"
@@ -152,7 +97,7 @@ assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-to
# ---------------------------------------------------------------------------
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
# ---------------------------------------------------------------------------
echo -n "agentB-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentB.token"
echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token"
git -C "$REPO_DIR" config mosaic.gitIdentity agentB
out=$(run_helper "git.mosaicstack.dev" "agentA")
assert_eq "git-config beats username: username" "username=agentB" "$(echo "$out" | grep '^username=')"
@@ -161,210 +106,54 @@ assert_eq "git-config beats username: password" "password=agentB-mosaicstack-tok
# ---------------------------------------------------------------------------
# 4. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
# ---------------------------------------------------------------------------
echo -n "agentC-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentC.token"
echo -n "agentC-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentC.token"
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_GIT_IDENTITY=agentC)
assert_eq "env beats git-config: username" "username=agentC" "$(echo "$out" | grep '^username=')"
assert_eq "env beats git-config: password" "password=agentC-mosaicstack-token" "$(echo "$out" | grep '^password=')"
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
# ---------------------------------------------------------------------------
# 5. Correct token PATH is chosen per host: same agent id, different host
# prefix (gitea-usc- vs gitea-mosaicstack-).
# 5. Identity resolves, but no matching per-slot token file -> falls back to
# the shared account (per-agent identity is opt-in, not a hard requirement).
# ---------------------------------------------------------------------------
echo -n "agentD-usc-token" > "$SVC_STORE/gitea-usc-agentD.token"
out=$(run_helper "git.mosaicstack.dev" "no-such-agent")
assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^username=')"
assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
# ---------------------------------------------------------------------------
# 6. Correct per-slot token PATH is chosen per host: same agent id, different
# host prefix (gitea-usc- vs gitea-mosaicstack-).
# ---------------------------------------------------------------------------
echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token"
out=$(run_helper "git.uscllc.com" "agentD")
assert_eq "host-scoped token path (usc): username" "username=agentD" "$(echo "$out" | grep '^username=')"
assert_eq "host-scoped token path (usc): password" "password=agentD-usc-token" "$(echo "$out" | grep '^password=')"
# agentD has NO mosaicstack token -> must fall back to shared mosaicstack, not
# leak the usc token across hosts.
out=$(run_helper "git.mosaicstack.dev" "agentD")
assert_eq "host-scoped token path (cross-host must not leak): username" "username=git" "$(echo "$out" | grep '^username=')"
assert_eq "host-scoped token path (cross-host must not leak): password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
# ---------------------------------------------------------------------------
# 6. FAIL CLOSED — identity resolves, no credential for it on this host. Must
# NOT borrow the shared account, and must NOT leak the same agent's token
# for a DIFFERENT host (agentD holds a usc token and no mosaicstack one).
# ---------------------------------------------------------------------------
assert_fail_closed "cross-host absence refuses (no shared fallback, no cross-host leak)" \
"git.mosaicstack.dev" "agentD" "gitea-mosaicstack-agentD.token"
# The agent's own usc token must not appear either.
: > "$WORK_DIR/stderr.tmp"
set +e
leak_out=$(run_helper "git.mosaicstack.dev" "agentD" 2>"$WORK_DIR/stderr.tmp")
set -e
if [[ "$leak_out$(cat "$WORK_DIR/stderr.tmp")" == *"agentD-usc-token"* ]]; then
echo "FAIL: cross-host leak — the usc token value appeared on a mosaicstack request" >&2
fail=1
fi
assert_fail_closed "unknown identity refuses (shared account never substituted)" \
"git.mosaicstack.dev" "no-such-agent" "no-token-for-identity"
# ---------------------------------------------------------------------------
# 7. A refusal leaves a durable spool record, and that record contains no token.
# The stderr diagnostic is transient; the record is what an operator reads
# afterwards, so it must exist independently of anyone watching the terminal.
# ---------------------------------------------------------------------------
spool_file=$(find "$SPOOL_DIR" -maxdepth 1 -name '*.jsonl' | head -n 1)
if [[ -z "$spool_file" ]]; then
echo "FAIL: fail-closed left no spool record under $SPOOL_DIR" >&2
fail=1
else
spool_body=$(cat "$spool_file")
assert_eq "spool record names the refused identity" "1" \
"$(grep -c '"identity":"no-such-agent"' "$spool_file" | head -n 1)"
if [[ "$spool_body" == *"shared-"*"-token"* || "$spool_body" == *"agentD-usc-token"* ]]; then
echo "FAIL: spool record contains a token value:" >&2
echo "$spool_body" >&2
fail=1
fi
fi
# ---------------------------------------------------------------------------
# 8. SEAT identity: an id with a directory under <brain>/fleet/agents/ is read
# from its OWN secrets/ slot, not from the framework store.
# ---------------------------------------------------------------------------
mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets"
echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
out=$(run_helper "git.mosaicstack.dev" "seatE" MOSAIC_BRAIN_HOME="$BRAIN_DIR")
assert_eq "seat reads its own slot: username" "username=seatE" "$(echo "$out" | grep '^username=')"
assert_eq "seat reads its own slot: password" "password=seatE-slot-token" "$(echo "$out" | grep '^password=')"
# ---------------------------------------------------------------------------
# 9. NO CROSS-STORE FALLBACK — the assertion this whole store-selection design
# exists for. seatF is a seat (it has a directory) with an EMPTY slot, while
# a framework-store token of the identical name is present and readable.
# The helper must refuse rather than read it: one credential, one location,
# and a seat that reads a same-named service credential is exactly the
# silent-substitution failure the fail-closed rule removes.
# ---------------------------------------------------------------------------
mkdir -p "$BRAIN_DIR/fleet/agents/seatF/secrets"
echo -n "seatF-SERVICE-STORE-token" > "$SVC_STORE/gitea-mosaicstack-seatF.token"
assert_fail_closed "seat with empty slot does NOT fall back to the framework store" \
"git.mosaicstack.dev" "seatF" "fleet/agents/seatF/secrets" MOSAIC_BRAIN_HOME="$BRAIN_DIR"
: > "$WORK_DIR/stderr.tmp"
set +e
xstore_out=$(run_helper "git.mosaicstack.dev" "seatF" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr.tmp")
set -e
if [[ "$xstore_out$(cat "$WORK_DIR/stderr.tmp")" == *"seatF-SERVICE-STORE-token"* ]]; then
echo "FAIL: cross-store fallback — a seat read the framework store's same-named token" >&2
fail=1
fi
# Control: that framework-store token IS readable, so the refusal above is the
# store rule firing and not an unreadable file. A non-seat identity pointed at
# the same file gets it.
out=$(run_helper "git.mosaicstack.dev" "seatF" MOSAIC_BRAIN_HOME="$WORK_DIR/no-such-brain")
assert_eq "control — same file IS readable for a non-seat identity" \
"password=seatF-SERVICE-STORE-token" "$(echo "$out" | grep '^password=')"
# ---------------------------------------------------------------------------
# 10. FAIL CLOSED — no identity resolvable, but this host runs a fleet. Where
# seats exist, an unattributable request is refused instead of receiving
# the shared account. Contrast with case 1, which is the same request on a
# host with no fleet and still returns the shared account.
# ---------------------------------------------------------------------------
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
assert_fail_closed "no identity on a fleet host refuses" \
"git.mosaicstack.dev" "" "no-identity" MOSAIC_BRAIN_HOME="$BRAIN_DIR"
# ---------------------------------------------------------------------------
# 11. The brain home defaults to ~/.mosaic when MOSAIC_BRAIN_HOME is unset —
# the fleet gate must fire on the default path too, not only on an
# explicitly injected one. Case 1 ran before this directory existed; the
# same call now refuses, which also proves case 1 was measuring the
# no-fleet branch rather than passing for an unrelated reason.
# ---------------------------------------------------------------------------
mkdir -p "$FAKE_HOME/.mosaic/fleet/agents"
assert_fail_closed "fleet gate fires on the default ~/.mosaic brain home" \
"git.mosaicstack.dev" "" "no-identity"
# ---------------------------------------------------------------------------
# 12. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
# remotes, e.g. github.com via a different credential helper). A fleet host
# must not refuse a host this helper does not own.
# 7. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
# remotes, e.g. github.com via a different credential helper).
# ---------------------------------------------------------------------------
out=$(run_helper "github.com" "agentA")
assert_eq "unknown host: no output" "" "$out"
out=$(run_helper "github.com" "" MOSAIC_BRAIN_HOME="$BRAIN_DIR")
assert_eq "unknown host on a fleet host: still passthrough, not a refusal" "" "$out"
# ---------------------------------------------------------------------------
# 13. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
# protocol: this helper only implements get).
# 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
# protocol: this helper only implements get).
# ---------------------------------------------------------------------------
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
host=git.mosaicstack.dev
username=no-such-agent
username=agentA
password=whatever
EOF
)
assert_eq "store verb: no output" "" "$store_out"
# ---------------------------------------------------------------------------
# 14. The escalation record is machine-readable even when a field carries a
# quote or a backslash. A cwd is arbitrary operator text; an unescaped one
# silently turns the spool into unparseable JSONL, and the operator only
# finds out while reading the record that explains an outage.
# ---------------------------------------------------------------------------
hostile_dir="$WORK_DIR/we\"ird\\dir"
mkdir -p "$hostile_dir"
hostile_spool="$WORK_DIR/spool-hostile"
(
cd "$hostile_dir"
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$hostile_spool" \
MOSAIC_GIT_IDENTITY=no-such-agent \
bash "$HELPER" get <<EOF >/dev/null 2>&1
host=git.mosaicstack.dev
username=no-such-agent
EOF
) || true
# Deliberately not `ls ... | head -1`: under `set -o pipefail` a missed glob
# makes ls exit 2, the pipeline inherits it, and `set -e` kills this suite with
# zero output — the same silent-nonzero failure this file exists to catch.
record_file=""
for candidate in "$hostile_spool"/*.jsonl; do
if [[ -e "$candidate" ]]; then
record_file="$candidate"
break
fi
done
if [[ -z "$record_file" ]]; then
echo "FAIL: hostile cwd — no escalation record was written at all" >&2
fail=1
elif ! python3 -c 'import json,sys
for line in open(sys.argv[1]):
line = line.strip()
if line:
json.loads(line)' "$record_file" 2>/dev/null; then
echo "FAIL: hostile cwd — escalation record is not parseable JSONL:" >&2
cat "$record_file" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 15. When the spool cannot be created, the diagnostic must NOT name a record
# path. Naming a file that was never written sends the operator to an
# empty path on exactly the hosts where the escalation was lost.
# ---------------------------------------------------------------------------
unwritable_spool="/proc/mosaic-credential-spool-cannot-exist"
nospool_err=$(
cd "$REPO_DIR"
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$unwritable_spool" \
MOSAIC_GIT_IDENTITY=no-such-agent \
bash "$HELPER" get <<EOF 2>&1 >/dev/null
host=git.mosaicstack.dev
username=no-such-agent
EOF
) || true
if [[ "$nospool_err" == *"record: $unwritable_spool/"* ]]; then
echo "FAIL: unwritable spool — diagnostic names a record file that was never written" >&2
fail=1
fi
if [[ "$nospool_err" != *"NOT WRITTEN"* ]]; then
echo "FAIL: unwritable spool — diagnostic does not say the record was not written" >&2
echo "$nospool_err" >&2
fail=1
fi
if [[ "$fail" -eq 0 ]]; then
echo "git-credential-mosaic identity resolution regression passed"
fi
@@ -23,17 +23,6 @@
# 6. Scope containment: identity requested + an UNRECOGNIZED Gitea host (no
# per-slot token scheme) -> Patch 2b does not apply; existing
# fall-through behavior is unchanged.
# 7. Credential store selection: an identity with a directory under
# <brain>/fleet/agents/ is a SEAT and is read ONLY from its own secrets/
# slot; any other identity is a SERVICE and is read from the framework
# store. No precedence between them and NO fallback from one to the
# other — a seat with an empty slot is REFUSED even when a same-named
# token sits in the framework store.
# 8. Fail loud when NO identity resolves on a host that runs a fleet: where
# seats exist, an unattributable API call is refused rather than made
# under the shared account. On a host with no fleet the same call still
# returns the shared token (case 1), which is what keeps this change a
# no-op for non-fleet operators of the framework.
#
# Uses a stubbed credentials.json + stubbed per-slot token files under a fake
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
@@ -221,94 +210,6 @@ if [[ "$err" == *"no per-slot token at"* ]]; then
fi
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
# ---------------------------------------------------------------------------
# 7. SEAT identity: an id with a directory under <brain>/fleet/agents/ is read
# from its OWN secrets/ slot, not from the framework store. The brain home
# is resolved exactly as packages/mosaic/src/fleet/brain-home.ts does it:
# MOSAIC_BRAIN_HOME, else ~/.mosaic.
# ---------------------------------------------------------------------------
BRAIN_DIR="$WORK_DIR/brain"
mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets"
echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatE MOSAIC_BRAIN_HOME="$BRAIN_DIR")
assert_eq "seat reads its own slot" "seatE-slot-token" "$out"
# ---------------------------------------------------------------------------
# 8. NO CROSS-STORE FALLBACK. seatF is a seat (it has a directory) with an
# EMPTY slot, while a framework-store token of the identical name is
# present and readable. It must be REFUSED rather than served that token:
# one credential, one location. A seat that silently reads a same-named
# service credential is the same substitution failure as the shared-account
# fallback, one store further down.
# ---------------------------------------------------------------------------
mkdir -p "$BRAIN_DIR/fleet/agents/seatF/secrets"
echo -n "seatF-SERVICE-STORE-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-seatF.token"
assert_failloud "seat with empty slot does NOT fall back to the framework store" \
"git.mosaicstack.dev" "seatF" \
"$BRAIN_DIR/fleet/agents/seatF/secrets/gitea-mosaicstack-seatF.token" \
MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR"
# assert_failloud only screens stderr for the word "shared"; this store's token
# is not named that, so check for its value explicitly.
set +e
xstore_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-xstore.tmp")
set -e
if [[ "$xstore_out$(cat "$WORK_DIR/stderr-xstore.tmp")" == *"seatF-SERVICE-STORE-token"* ]]; then
echo "FAIL: cross-store fallback — a seat was served the framework store's same-named token" >&2
fail=1
fi
# Control: that framework-store token IS readable, so the refusal above is the
# store rule firing and not an unreadable file. The same id, resolved against a
# brain home where it is not a seat, gets it.
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$WORK_DIR/no-such-brain")
assert_eq "control — same file IS readable for a non-seat identity" "seatF-SERVICE-STORE-token" "$out"
# ---------------------------------------------------------------------------
# 9. FAIL LOUD — no identity resolvable, but this host runs a fleet. Contrast
# with case 1: the identical call on a host with no fleet still returns the
# shared token.
# ---------------------------------------------------------------------------
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
set +e
noid_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-noid.tmp")
noid_rc=$?
set -e
noid_err=$(cat "$WORK_DIR/stderr-noid.tmp")
if [[ "$noid_rc" -eq 0 ]]; then
echo "FAIL: no identity on a fleet host — expected nonzero return, got 0 (stdout='$noid_out')" >&2
fail=1
fi
if [[ -n "$noid_out" ]]; then
echo "FAIL: no identity on a fleet host — expected empty stdout, got '$noid_out'" >&2
fail=1
fi
if [[ "$noid_out" == *"shared-mosaicstack-token"* || "$noid_err" == *"shared-mosaicstack-token"* ]]; then
echo "FAIL: no identity on a fleet host — the shared token was served anyway:" >&2
echo "$noid_out$noid_err" >&2
fail=1
fi
if [[ "$noid_err" != *"MOSAIC_GIT_IDENTITY"* ]]; then
echo "FAIL: no identity on a fleet host — stderr does not say how to set an identity:" >&2
echo "$noid_err" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 10. The brain home defaults to ~/.mosaic when MOSAIC_BRAIN_HOME is unset —
# the fleet gate must fire on the default path too, not only on an
# explicitly injected one. Case 1 ran before this directory existed; the
# same call now refuses, which also proves case 1 was measuring the
# no-fleet branch rather than passing for an unrelated reason.
# ---------------------------------------------------------------------------
mkdir -p "$FAKE_HOME/.mosaic/fleet/agents"
set +e
dflt_out=$(call_get_gitea_token "git.mosaicstack.dev" 2>"$WORK_DIR/stderr-dflt.tmp")
dflt_rc=$?
set -e
if [[ "$dflt_rc" -eq 0 || -n "$dflt_out" ]]; then
echo "FAIL: fleet gate did not fire on the default ~/.mosaic brain home (rc=$dflt_rc stdout='$dflt_out')" >&2
fail=1
fi
if [[ "$fail" -eq 0 ]]; then
echo "get_gitea_token identity resolution regression passed"
fi
@@ -1,149 +0,0 @@
import { mkdtemp, readFile, readdir } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { parse as parseYaml } from 'yaml';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerMissionCommand } from './mission.js';
import { PrdService } from '@mosaicstack/prdy';
import type { MissionInfo } from '../tui/gateway-api.js';
// ── Mocks: the gateway is not available in adapter tests ──────────────────────
// vi.hoisted: the mock factory is hoisted above imports, so the fixture must
// be initialized there too.
const MISSION = vi.hoisted(
(): MissionInfo => ({
id: 'mission-plan-1',
name: 'Plan Mission Alpha',
description: null,
status: 'planning',
projectId: null,
userId: null,
phase: null,
milestones: null,
config: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-03-04T05:06:07.000Z',
}),
);
vi.mock('./with-auth.js', () => ({
withAuth: vi.fn().mockResolvedValue({
gateway: 'http://localhost:14242',
cookie: 'better-auth.session_token=test',
session: {},
}),
}));
vi.mock('../tui/gateway-api.js', () => ({
fetchMissions: vi.fn().mockResolvedValue([MISSION]),
fetchMission: vi.fn(),
createMission: vi.fn(),
updateMission: vi.fn(),
fetchMissionTasks: vi.fn().mockResolvedValue([]),
createMissionTask: vi.fn(),
updateMissionTask: vi.fn(),
fetchProjects: vi.fn().mockResolvedValue([]),
}));
// ── Helpers ──────────────────────────────────────────────────────────────────
const originalCwd = process.cwd();
let projectDir: string;
let logSpy: ReturnType<typeof vi.spyOn>;
let consoleStub: ReturnType<typeof vi.spyOn>[] = [];
function buildTestProgram(): Command {
const program = new Command('mosaic').exitOverride();
registerMissionCommand(program);
return program;
}
beforeEach(async () => {
projectDir = await mkdtemp(path.join(os.tmpdir(), 'mosaic-mission-plan-'));
process.chdir(projectDir);
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleStub.push(logSpy);
});
afterEach(() => {
// Restore only the per-test spies; module factory mocks keep their
// implementations across tests.
for (const stub of consoleStub) stub.mockRestore();
consoleStub = [];
process.chdir(originalCwd);
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe('mosaic mission --plan (thin adapter over PrdService)', () => {
it('creates the PRD in the shared docs/prdy authority store and persists the mission linkage', async () => {
await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], {
from: 'user',
});
// PRD landed in the same store `mosaic prdy` uses.
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/\.yaml$/);
// Fresh service instance (new-process equivalent) reads the linkage back.
const service = new PrdService({ projectPath: projectDir });
const docs = await service.list();
expect(docs).toHaveLength(1);
const prd = docs[0]!;
expect(prd.title).toBe('Plan Mission Alpha');
expect(prd.version).toBe(1);
const links = await service.listMissionLinks(prd.id);
expect(links).toHaveLength(1);
expect(links[0]).toMatchObject({
missionId: MISSION.id,
missionVersion: MISSION.updatedAt, // mission version marker
prdVersion: 1,
});
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PRD created and linked'));
});
it('linkage is persisted in the YAML authority document itself (survives restart)', async () => {
await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], {
from: 'user',
});
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
const raw = await readFile(path.join(projectDir, 'docs', 'prdy', files[0]!), 'utf8');
const persisted = parseYaml(raw) as { missions: Array<Record<string, unknown>> };
expect(persisted.missions).toHaveLength(1);
expect(persisted.missions[0]).toMatchObject({ missionId: 'mission-plan-1' });
});
it('the mission path and the prdy path resolve to the same store with stable ids/versions', async () => {
// Mission path.
await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], {
from: 'user',
});
// prdy path (service, non-interactive entry).
const service = new PrdService({ projectPath: projectDir });
const direct = await service.create({ name: 'Directly Created' });
const all = await service.list();
expect(all.map((doc) => doc.id).sort()).toEqual([...all.map((doc) => doc.id)].sort());
expect(all).toHaveLength(2);
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
expect(files).toContain(`${direct.id}.yaml`);
// Both are v1 in the same store with distinct stable ids.
for (const doc of all) {
expect(doc.version).toBe(1);
expect(files).toContain(`${doc.id}.yaml`);
}
});
});
+5 -32
View File
@@ -256,41 +256,14 @@ async function planMission(
console.log(`Planning mission: ${mission.name}\n`);
try {
// Thin adapter: the PRD authority (create + mission↔PRD linkage) lives in
// PrdService — no second writer path. The mission's updatedAt serves as
// its version marker (the gateway exposes no numeric mission version).
const { PrdService, runPrdWizard } = await import('@mosaicstack/prdy');
const service = new PrdService({ projectPath: process.cwd() });
if (process.stdout.isTTY) {
const created = await runPrdWizard({
name: mission.name,
projectPath: process.cwd(),
interactive: true,
});
const linked = await service.linkMission({
prdId: created.id,
missionId: mission.id,
missionVersion: mission.updatedAt,
requirementIds: [],
});
console.log(
`\nMission ${mission.id} linked to PRD ${linked.id} v${linked.version} (docs/prdy/).`,
);
return;
}
const doc = await service.planForMission({
const { runPrdWizard } = await import('@mosaicstack/prdy');
await runPrdWizard({
name: mission.name,
missionId: mission.id,
missionVersion: mission.updatedAt,
requirementIds: [],
projectPath: process.cwd(),
interactive: true,
});
console.log(
`PRD created and linked: ${doc.id} v${doc.version} — mission ${mission.id} (docs/prdy/).`,
);
} catch (err) {
console.error(`PRD planning failed: ${err instanceof Error ? err.message : String(err)}`);
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
-204
View File
@@ -1,204 +0,0 @@
import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { stringify as stringifyYaml } from 'yaml';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerPrdyCommand } from './prdy.js';
import { PrdService } from '@mosaicstack/prdy';
// ── Mocks: keep the adapter test offline (no gateway, no disk side effects
// outside the tmp project dir) ──────────────────────────────────────────────
vi.mock('./with-auth.js', () => ({
withAuth: vi.fn().mockResolvedValue({
gateway: 'http://localhost:14242',
cookie: 'better-auth.session_token=test',
session: {},
}),
}));
vi.mock('../tui/gateway-api.js', () => ({
fetchProjects: vi.fn().mockResolvedValue([]),
}));
// ── Helpers ──────────────────────────────────────────────────────────────────
class ProcessExitError extends Error {
constructor(readonly code: number) {
super(`process.exit(${code})`);
}
}
function stubProcessExit() {
return vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new ProcessExitError(code ?? 0);
}) as never);
}
const originalCwd = process.cwd();
let projectDir: string;
let errorSpy: ReturnType<typeof vi.spyOn>;
let logSpy: ReturnType<typeof vi.spyOn>;
let exitStub: ReturnType<typeof stubProcessExit>;
function buildTestProgram(): Command {
const program = new Command('mosaic').exitOverride();
registerPrdyCommand(program);
return program;
}
function runPrdy(args: string[]): Promise<unknown> {
return buildTestProgram().parseAsync(['prdy', ...args], { from: 'user' });
}
function importableDocument(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'cmd-import-prd',
title: 'Command Import PRD',
status: 'approved', // must be forced to draft: validity is not approval
projectPath: '/tmp/elsewhere',
template: 'software',
version: 1,
sections: [
{ id: 'introduction', title: 'Introduction', fields: { context: 'x', objective: 'y' } },
],
missions: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
};
}
beforeEach(async () => {
projectDir = await mkdtemp(path.join(os.tmpdir(), 'mosaic-prdy-'));
process.chdir(projectDir);
exitStub = stubProcessExit();
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
// Restore only the per-test spies: module factory mocks must keep their
// implementations for the next test.
exitStub.mockRestore();
errorSpy.mockRestore();
logSpy.mockRestore();
process.chdir(originalCwd);
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe('mosaic prdy (thin adapter over PrdService)', () => {
it('non-interactive --init creates a PRD in the docs/prdy authority store', async () => {
await runPrdy(['--init', 'Adapter Created']);
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/\.yaml$/);
const docs = await new PrdService({ projectPath: projectDir }).list();
expect(docs).toHaveLength(1);
expect(docs[0]?.title).toBe('Adapter Created');
expect(docs[0]?.version).toBe(1);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PRD created'));
});
it('--import <file> creates a valid import through the service', async () => {
const filePath = path.join(projectDir, 'incoming.yaml');
await writeFile(filePath, stringifyYaml(importableDocument()), 'utf8');
await runPrdy(['--import', filePath]);
const docs = await new PrdService({ projectPath: projectDir }).list();
expect(docs).toHaveLength(1);
expect(docs[0]?.id).toBe('cmd-import-prd');
expect(docs[0]?.status).toBe('draft'); // import ≠ approval
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Imported PRD cmd-import-prd'));
});
it('--import of a structurally-invalid file is a typed refusal that creates nothing', async () => {
const filePath = path.join(projectDir, 'broken.yaml');
await writeFile(filePath, stringifyYaml({ id: 'incomplete', no: 'structure' }), 'utf8');
await expect(runPrdy(['--import', filePath])).rejects.toBeInstanceOf(ProcessExitError);
// Typed refusal surfaced to the user, nothing created.
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('PRD wizard failed'));
await expect(readdir(path.join(projectDir, 'docs'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('--import on conflict refuses with a successor proposal and leaves bytes untouched', async () => {
const service = new PrdService({ projectPath: projectDir });
const existing = await service.create({ name: 'Conflict Target' });
const storeFile = path.join(projectDir, 'docs', 'prdy', `${existing.id}.yaml`);
const beforeBytes = await readFile(storeFile, 'utf8');
const filePath = path.join(projectDir, 'divergent.yaml');
await writeFile(
filePath,
stringifyYaml(
importableDocument({
...existing,
title: 'Divergent Command Import',
}),
),
'utf8',
);
await expect(runPrdy(['--import', filePath])).rejects.toBeInstanceOf(ProcessExitError);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('refusing to overwrite'));
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--accept-successor'));
// Original authority document is byte-identical on disk.
expect(await readFile(storeFile, 'utf8')).toBe(beforeBytes);
});
it('--import --accept-successor persists the successor version explicitly', async () => {
const service = new PrdService({ projectPath: projectDir });
const existing = await service.create({ name: 'Successor Target' });
const filePath = path.join(projectDir, 'divergent2.yaml');
await writeFile(
filePath,
stringifyYaml(
importableDocument({
...existing,
title: 'Accepted Via CLI',
}),
),
'utf8',
);
await runPrdy(['--import', filePath, '--accept-successor']);
const doc = await service.get(existing.id);
expect(doc.version).toBe(2);
expect(doc.title).toBe('Accepted Via CLI');
expect(doc.status).toBe('draft');
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('successor'));
});
it('--export writes a labeled generated view and never touches authority', async () => {
const service = new PrdService({ projectPath: projectDir });
const created = await service.create({ name: 'Export Via CLI' });
const before = await service.get(created.id);
await runPrdy(['--export', created.id]);
const mdPath = path.join(projectDir, 'docs', 'prdy', `${created.id}.md`);
const md = await readFile(mdPath, 'utf8');
expect(md).toContain('generated view — do not edit');
expect(md).toContain(`prd-id: ${created.id}`);
expect(md).toContain('prd-version: 1');
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining(`Generated view written: ${mdPath}`),
);
// Authority unchanged by the export.
expect(await service.get(created.id)).toEqual(before);
});
});
+6 -65
View File
@@ -2,10 +2,6 @@ import type { Command } from 'commander';
import { withAuth } from './with-auth.js';
import { fetchProjects } from '../tui/gateway-api.js';
/**
* `mosaic prdy` thin adapter over PrdService (@mosaicstack/prdy).
* All reads/writes go through the service; there is no local writer path.
*/
export function registerPrdyCommand(program: Command) {
const cmd = program
.command('prdy')
@@ -13,18 +9,12 @@ export function registerPrdyCommand(program: Command) {
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('--init [name]', 'Create a new PRD')
.option('--update [name]', 'Update an existing PRD')
.option('--import <file>', 'Import a YAML PRD document (validated, conflict-aware)')
.option('--accept-successor', 'With --import: accept a conflicted import as next version')
.option('--export [id]', 'Export a PRD as a labeled generated-view Markdown file')
.option('--project <idOrName>', 'Scope to project')
.action(
async (opts: {
gateway: string;
init?: string | boolean;
update?: string | boolean;
import?: string;
acceptSuccessor?: boolean;
export?: string | boolean;
project?: string;
}) => {
// Detect project context when --project flag is provided
@@ -41,69 +31,20 @@ export function registerPrdyCommand(program: Command) {
}
}
const { PrdService, runPrdWizard } = await import('@mosaicstack/prdy');
const service = new PrdService({ projectPath: process.cwd() });
try {
if (opts.import !== undefined) {
const input = { filePath: opts.import };
if (opts.acceptSuccessor) {
const successor = await service.acceptSuccessor(input);
console.log(
`Import accepted as successor: ${successor.id} v${successor.version} (status: ${successor.status})`,
);
return;
}
const result = await service.importDocument(input);
console.log(
result.kind === 'created'
? `Imported PRD ${result.document.id} v${result.document.version} (status: ${result.document.status})`
: `PRD ${result.document.id} already present with identical content — nothing to do.`,
);
return;
}
if (opts.export !== undefined) {
const id =
typeof opts.export === 'string' && opts.export.length > 0 ? opts.export : undefined;
const result = await service.exportMarkdown({ id });
console.log(
`Generated view written: ${result.filePath} (source authority: YAML under docs/prdy/ — do not edit the Markdown)`,
);
return;
}
const { runPrdWizard } = await import('@mosaicstack/prdy');
const name =
typeof opts.init === 'string'
? opts.init
: typeof opts.update === 'string'
? opts.update
: 'untitled';
if (process.stdout.isTTY) {
await runPrdWizard({
name,
projectPath: process.cwd(),
interactive: true,
});
return;
}
// Non-interactive fallback routes through the service directly.
const doc = await service.create({ name });
console.log(`PRD created: ${doc.id} v${doc.version} (status: ${doc.status})`);
await runPrdWizard({
name,
projectPath: process.cwd(),
interactive: true,
});
} catch (err) {
if (err instanceof Error && err.name === 'PrdImportConflictError') {
const conflict = err as { proposal?: { version?: number } };
console.error(`${err.message}`);
console.error(
`Original PRD left untouched. To accept the proposed successor (v${conflict.proposal?.version}), re-run with --accept-successor.`,
);
process.exit(1);
}
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
+14 -74
View File
@@ -1,6 +1,6 @@
import { Command } from 'commander';
import { PrdService } from './service.js';
import { createPrd, listPrds, loadPrd } from './prd.js';
import { runPrdWizard } from './wizard.js';
interface InitCommandOptions {
@@ -18,22 +18,6 @@ interface ShowCommandOptions {
readonly id?: string;
}
interface ImportCommandOptions {
readonly project: string;
readonly file: string;
readonly acceptSuccessor?: boolean;
}
interface ExportCommandOptions {
readonly project: string;
readonly id?: string;
readonly out?: string;
}
function serviceFor(project: string): PrdService {
return new PrdService({ projectPath: project });
}
export function buildPrdyCli(): Command {
const program = new Command();
program.name('mosaic').description('Mosaic CLI').exitOverride();
@@ -54,9 +38,11 @@ export function buildPrdyCli(): Command {
template: options.template,
interactive: true,
})
: await serviceFor(options.project).create({
: await createPrd({
name: options.name,
projectPath: options.project,
template: options.template,
interactive: false,
});
console.log(
@@ -66,7 +52,6 @@ export function buildPrdyCli(): Command {
id: doc.id,
title: doc.title,
status: doc.status,
version: doc.version,
projectPath: doc.projectPath,
},
null,
@@ -80,7 +65,7 @@ export function buildPrdyCli(): Command {
.description('List PRD documents for a project')
.requiredOption('--project <path>', 'Project path')
.action(async (options: ListCommandOptions) => {
const docs = await serviceFor(options.project).list();
const docs = await listPrds(options.project);
console.log(JSON.stringify(docs, null, 2));
});
@@ -90,65 +75,20 @@ export function buildPrdyCli(): Command {
.requiredOption('--project <path>', 'Project path')
.option('--id <id>', 'PRD document id')
.action(async (options: ShowCommandOptions) => {
const doc = await serviceFor(options.project).get(options.id);
console.log(JSON.stringify(doc, null, 2));
});
if (options.id !== undefined) {
const docs = await listPrds(options.project);
const match = docs.find((doc) => doc.id === options.id);
prdy
.command('import')
.description('Import a YAML PRD document (validated; conflicts propose a successor)')
.requiredOption('--project <path>', 'Project path')
.requiredOption('--file <file>', 'Path to YAML PRD document')
.option('--accept-successor', 'Accept a conflicted import as the next version')
.action(async (options: ImportCommandOptions) => {
const service = serviceFor(options.project);
const input = { filePath: options.file };
if (match === undefined) {
throw new Error(`PRD id not found: ${options.id}`);
}
if (options.acceptSuccessor) {
const successor = await service.acceptSuccessor(input);
console.log(
JSON.stringify(
{
ok: true,
outcome: 'successor-accepted',
id: successor.id,
version: successor.version,
},
null,
2,
),
);
console.log(JSON.stringify(match, null, 2));
return;
}
const result = await service.importDocument(input);
console.log(
JSON.stringify(
{
ok: true,
outcome: result.kind,
id: result.document.id,
version: result.document.version,
status: result.document.status,
},
null,
2,
),
);
});
prdy
.command('export')
.description('Render a PRD to a labeled generated-view Markdown file')
.requiredOption('--project <path>', 'Project path')
.option('--id <id>', 'PRD document id')
.option('--out <path>', 'Output path (default docs/prdy/<id>.md)')
.action(async (options: ExportCommandOptions) => {
const result = await serviceFor(options.project).exportMarkdown({
id: options.id,
outPath: options.out,
});
console.log(JSON.stringify({ ok: true, filePath: result.filePath }, null, 2));
const doc = await loadPrd(options.project);
console.log(JSON.stringify(doc, null, 2));
});
return program;
+1 -24
View File
@@ -1,35 +1,12 @@
// PrdService is the single authority surface for PRD documents. The raw store
// writers (createPrd/savePrd) are deliberately NOT exported: every mutation
// goes through the service so there is no second writer path.
export { loadPrd, listPrds, parsePrdDocument } from './prd.js';
export { createPrd, loadPrd, savePrd, listPrds } from './prd.js';
export { runPrdWizard } from './wizard.js';
export { buildPrdyCli, runPrdyCli } from './cli.js';
export { BUILTIN_PRD_TEMPLATES, resolveTemplate } from './templates.js';
export {
PrdService,
PRD_GENERATED_VIEW_LABEL,
PrdError,
PrdNotFoundError,
PrdUpdateError,
PrdImportInvalidError,
PrdImportConflictError,
} from './service.js';
export type {
PrdStatus,
PrdTemplate,
PrdTemplateSection,
PrdSection,
PrdMissionLinkage,
PrdDocument,
CreatePrdOptions,
PrdServiceOptions,
PrdCreateInput,
PrdSectionPatch,
PrdUpdateInput,
PrdLinkMissionInput,
PrdPlanForMissionInput,
PrdExportInput,
PrdExportResult,
PrdImportInput,
PrdImportResult,
} from './types.js';
+1 -37
View File
@@ -17,49 +17,17 @@ const prdSectionSchema = z.object({
fields: z.record(z.string(), z.string()),
});
const prdMissionLinkageSchema = z.object({
missionId: z.string().min(1),
missionVersion: z.string().min(1),
prdVersion: z.number().int().min(1),
requirementIds: z.array(z.string()),
linkedAt: z.string().datetime(),
});
const prdDocumentSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
status: z.enum(['draft', 'review', 'approved', 'archived']),
projectPath: z.string().min(1),
template: z.string().min(1),
// Defaults keep documents written by older prdy versions loadable.
version: z.number().int().min(1).default(1),
sections: z.array(prdSectionSchema),
missions: z.array(prdMissionLinkageSchema).default([]),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
/** YAML timestamp scalars are parsed as Date by some emitters — normalize to ISO strings. */
function coerceTimestamps(value: unknown): unknown {
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return value.map(coerceTimestamps);
}
if (typeof value === 'object' && value !== null) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, coerceTimestamps(entry)]),
);
}
return value;
}
/** Validate an unknown value as a PRD document (throws zod errors on failure). */
export function parsePrdDocument(value: unknown): PrdDocument {
return prdDocumentSchema.parse(coerceTimestamps(value)) as PrdDocument;
}
function expandHome(projectPath: string): string {
if (!projectPath.startsWith('~')) {
return projectPath;
@@ -106,8 +74,6 @@ function prdDirectory(projectPath: string): string {
return path.join(projectPath, PRD_DIRECTORY);
}
export { prdDirectory };
function prdFilePath(projectPath: string, id: string): string {
return path.join(prdDirectory(projectPath), `${id}.yaml`);
}
@@ -147,13 +113,11 @@ export async function createPrd(options: CreatePrdOptions): Promise<PrdDocument>
status: 'draft',
projectPath: resolvedProjectPath,
template: template.id,
version: 1,
sections: template.sections.map((section) => ({
id: section.id,
title: section.title,
fields: Object.fromEntries(section.fields.map((field) => [field, ''])),
})),
missions: [],
createdAt: now,
updatedAt: now,
};
@@ -226,7 +190,7 @@ export async function listPrds(projectPath: string): Promise<PrdDocument[]> {
throw new Error(`Failed to parse PRD file ${filePath}: ${String(error)}`);
}
const document = parsePrdDocument(parsed);
const document = prdDocumentSchema.parse(parsed);
documents.push(document);
}
-433
View File
@@ -1,433 +0,0 @@
import { existsSync } from 'node:fs';
import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import yaml from 'js-yaml';
import { beforeEach, describe, expect, it } from 'vitest';
import {
PRD_GENERATED_VIEW_LABEL,
PrdImportConflictError,
PrdImportInvalidError,
PrdNotFoundError,
PrdService,
PrdUpdateError,
} from './index.js';
import type { PrdDocument } from './index.js';
// ── Helpers ──────────────────────────────────────────────────────────────────
let projectDir: string;
async function makeProject(): Promise<string> {
return mkdtemp(path.join(os.tmpdir(), 'prdy-service-'));
}
function service(): PrdService {
return new PrdService({ projectPath: projectDir });
}
function storeDir(): string {
return path.join(projectDir, 'docs', 'prdy');
}
/** Handcraft a full, schema-valid PRD document for import scenarios. */
function importFixture(overrides: Partial<PrdDocument> = {}): PrdDocument {
return {
id: 'imported-prd-20260101-000000',
title: 'Imported PRD',
status: 'draft',
projectPath: '/tmp/elsewhere',
template: 'software',
version: 1,
sections: [
{ id: 'introduction', title: 'Introduction', fields: { context: '', objective: '' } },
{
id: 'scope-non-goals',
title: 'Scope / Non-Goals',
fields: { inScope: '', outOfScope: '' },
},
],
missions: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
};
}
async function writeImportFile(doc: PrdDocument): Promise<string> {
const filePath = path.join(projectDir, `${doc.id}.import.yaml`);
await writeFile(filePath, yaml.dump(doc), 'utf8');
return filePath;
}
beforeEach(async () => {
projectDir = await makeProject();
});
// ── Single authority store (AC: prdy path and mission path resolve to the
// SAME store under docs/prdy/ with stable ids/versions) ────────────────────
describe('PrdService single authority store', () => {
it('persists PRDs from the prdy path and the mission path into the same docs/prdy store', async () => {
const direct = await service().create({ name: 'Direct PRD' });
const viaMission = await service().planForMission({
name: 'Mission PRD',
missionId: 'mission-1',
missionVersion: '2026-01-01T00:00:00.000Z',
});
const files = await readdir(storeDir());
expect(files).toContain(`${direct.id}.yaml`);
expect(files).toContain(`${viaMission.id}.yaml`);
// A fresh service instance (new process equivalent) resolves both.
const all = await service().list();
expect(all.map((doc) => doc.id).sort()).toEqual([direct.id, viaMission.id].sort());
// Stable versions: creation is v1; linkage writes do not bump content version.
expect((await service().get(direct.id)).version).toBe(1);
expect((await service().get(viaMission.id)).version).toBe(1);
});
it('round-trips documents through the store with identity intact', async () => {
const created = await service().create({ name: 'Round Trip', template: 'feature' });
const fresh = await service().get(created.id);
expect(fresh).toEqual(created);
expect(fresh.id).toBe(created.id);
expect(fresh.template).toBe('feature');
expect(fresh.status).toBe('draft');
});
it('throws a typed error for unknown ids and empty stores', async () => {
await expect(service().get('nope')).rejects.toBeInstanceOf(PrdNotFoundError);
await expect(service().get()).rejects.toBeInstanceOf(PrdNotFoundError);
});
});
// ── Mission linkage persistence (AC: linkage survives restart via fresh
// service instances) ────────────────────────────────────────────────────────
describe('PrdService mission linkage', () => {
it('persists linkage and reads it back from a fresh service instance', async () => {
const created = await service().planForMission({
name: 'Linked PRD',
missionId: 'mission-42',
missionVersion: '2026-02-03T04:05:06.000Z',
requirementIds: ['FR-1', 'FR-2'],
});
// Fresh instance — nothing in memory from the creating call.
const links = await service().listMissionLinks(created.id);
expect(links).toHaveLength(1);
expect(links[0]).toMatchObject({
missionId: 'mission-42',
missionVersion: '2026-02-03T04:05:06.000Z',
prdVersion: 1,
requirementIds: ['FR-1', 'FR-2'],
});
// Linkage is carried in the YAML authority file itself.
const raw = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
const persisted = yaml.load(raw) as PrdDocument;
expect(persisted.missions[0]?.missionId).toBe('mission-42');
expect(persisted.missions[0]?.requirementIds).toEqual(['FR-1', 'FR-2']);
});
it('refreshes an existing linkage entry in place instead of duplicating', async () => {
const created = await service().planForMission({
name: 'Relink PRD',
missionId: 'mission-7',
missionVersion: 'v1',
});
await service().update({
id: created.id,
sections: [{ id: 'introduction', fields: { objective: 'Ship it' } }],
});
const relinked = await service().linkMission({
prdId: created.id,
missionId: 'mission-7',
missionVersion: 'v2',
requirementIds: ['NFR-1'],
});
expect(relinked.missions).toHaveLength(1);
expect(relinked.missions[0]).toMatchObject({ missionVersion: 'v2', prdVersion: 2 });
});
it('does not bump the content version when writing linkage', async () => {
const created = await service().create({ name: 'Stable Version' });
const linked = await service().linkMission({
prdId: created.id,
missionId: 'm',
missionVersion: 'v1',
});
expect(linked.version).toBe(1);
});
});
// ── Update semantics ──────────────────────────────────────────────────────────
describe('PrdService update', () => {
it('applies section patches and bumps the content version', async () => {
const created = await service().create({ name: 'Updatable' });
const updated = await service().update({
id: created.id,
sections: [{ id: 'introduction', fields: { context: 'Some context', objective: 'Goal' } }],
});
expect(updated.version).toBe(2);
expect(updated.sections[0]?.fields).toMatchObject({
context: 'Some context',
objective: 'Goal',
});
expect((await service().get(created.id)).version).toBe(2);
});
it('refuses unknown section ids with a typed error', async () => {
const created = await service().create({ name: 'Strict' });
await expect(
service().update({ id: created.id, sections: [{ id: 'nope', fields: {} }] }),
).rejects.toBeInstanceOf(PrdUpdateError);
});
});
// ── Markdown export is a labeled generated view, never authority ──────────────
describe('PrdService exportMarkdown', () => {
it('writes a generated view carrying the label and source identity', async () => {
const created = await service().create({ name: 'Exported PRD' });
const result = await service().exportMarkdown({ id: created.id });
expect(result.filePath).toBe(path.join(storeDir(), `${created.id}.md`));
expect(result.content).toContain(PRD_GENERATED_VIEW_LABEL);
expect(result.content).toContain(`prd-id: ${created.id}`);
expect(result.content).toContain('prd-version: 1');
expect(result.content).toContain(`source-of-truth: docs/prdy/${created.id}.yaml`);
});
it('reflects the current version after updates', async () => {
const created = await service().create({ name: 'Versioned Export' });
await service().update({
id: created.id,
sections: [{ id: 'introduction', fields: { objective: 'v2 goal' } }],
});
const result = await service().exportMarkdown({ id: created.id });
expect(result.content).toContain('prd-version: 2');
});
it('NEGATIVE CONTROL: mutating the exported Markdown cannot change the authority', async () => {
const created = await service().create({ name: 'Guarded PRD' });
const before = structuredClone(await service().get(created.id));
const result = await service().exportMarkdown({ id: created.id });
await writeFile(
result.filePath,
`<!-- ${PRD_GENERATED_VIEW_LABEL} -->\n# FAKE\nprd-id: fake-id\nprd-version: 99\n`,
'utf8',
);
const after = await service().get(created.id);
expect(after).toEqual(before);
expect(after.version).toBe(1);
expect(after.title).toBe(before.title);
});
it('never parses Markdown files that sit in the store directory', async () => {
const created = await service().create({ name: 'Decoy Guard' });
// A decoy .md file with invalid YAML must be invisible to the store.
await writeFile(path.join(storeDir(), 'decoy.md'), 'not: [valid: yaml', 'utf8');
// And a decoy .yaml-named Markdown body must not silently validate either.
await service().exportMarkdown({ id: created.id });
const listed = await service().list();
expect(listed.map((doc) => doc.id)).toEqual([created.id]);
await expect(service().get(created.id)).resolves.toBeTruthy();
});
});
// ── Import: validated, conflict-aware, never silently merging ─────────────────
describe('PrdService importDocument', () => {
it('creates a valid import through the service, as draft — validity is not approval', async () => {
const filePath = await writeImportFile(importFixture({ status: 'approved' }));
const result = await service().importDocument({ filePath });
expect(result.kind).toBe('created');
expect(result.document.id).toBe('imported-prd-20260101-000000');
expect(result.document.status).toBe('draft'); // structural validity ≠ approval
expect(result.document.version).toBe(1);
const persisted = await service().get('imported-prd-20260101-000000');
expect(persisted.status).toBe('draft');
const files = await readdir(storeDir());
expect(files).toContain('imported-prd-20260101-000000.yaml');
});
it('reports identical content as a no-op without writing', async () => {
const created = await service().create({ name: 'Existing PRD' });
const before = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
const filePath = await writeImportFile(importFixture({ ...created }));
const result = await service().importDocument({ filePath });
expect(result.kind).toBe('identical');
const after = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
expect(after).toBe(before);
});
it('refuses a conflicting import with a typed error, a proposed successor, and untouched bytes', async () => {
const existing = await service().create({ name: 'Authority PRD' });
await service().linkMission({
prdId: existing.id,
missionId: 'mission-keep',
missionVersion: 'v1',
requirementIds: ['FR-0'],
});
const beforeBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8');
const divergent = importFixture({
...existing,
title: 'Divergent Title',
sections: [
{
id: 'introduction',
title: 'Introduction',
fields: { context: 'changed', objective: '' },
},
],
});
const filePath = await writeImportFile(divergent);
const attempt = service().importDocument({ filePath });
let caught: unknown;
try {
await attempt;
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(PrdImportConflictError);
const error = caught as PrdImportConflictError;
expect(error.code).toBe('PRD_IMPORT_CONFLICT');
expect(error.existing.id).toBe(existing.id);
expect(error.proposal.version).toBe(existing.version + 1); // successor proposal
expect(error.proposal.status).toBe('draft');
// Original authority content untouched on disk.
const afterBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8');
expect(afterBytes).toBe(beforeBytes);
});
it('acceptSuccessor persists the proposal explicitly, carrying linkages forward', async () => {
const existing = await service().create({ name: 'Successor Base' });
await service().linkMission({
prdId: existing.id,
missionId: 'mission-keep',
missionVersion: 'v1',
});
const divergent = importFixture({
...existing,
title: 'Accepted Successor Title',
});
const filePath = await writeImportFile(divergent);
const successor = await service().acceptSuccessor({ filePath });
expect(successor.id).toBe(existing.id);
expect(successor.version).toBe(existing.version + 1);
expect(successor.title).toBe('Accepted Successor Title');
expect(successor.status).toBe('draft');
expect(successor.missions.map((m) => m.missionId)).toEqual(['mission-keep']);
// Persisted for a fresh reader.
const fresh = await service().get(existing.id);
expect(fresh.version).toBe(2);
expect(fresh.title).toBe('Accepted Successor Title');
});
it('refuses structurally-invalid imports with a typed error and creates nothing', async () => {
const cases: Array<{ name: string; body: string }> = [
{ name: 'missing-title.yaml', body: yaml.dump({ id: 'x', status: 'draft' }) },
{
name: 'bad-status.yaml',
body: yaml.dump(importFixture({ status: 'not-a-status' as PrdDocument['status'] })),
},
{
name: 'bad-version.yaml',
body: yaml.dump(importFixture({ version: 0 })),
},
{ name: 'not-yaml.yaml', body: '::: not yaml [\n - {' },
];
for (const fixture of cases) {
const filePath = path.join(projectDir, fixture.name);
await writeFile(filePath, fixture.body, 'utf8');
await expect(service().importDocument({ filePath })).rejects.toBeInstanceOf(
PrdImportInvalidError,
);
}
// Nothing was created: the authority store does not even exist yet.
await expect(readdir(storeDir())).rejects.toMatchObject({ code: 'ENOENT' });
});
it('acceptSuccessor refuses when there is no existing document to succeed', async () => {
const filePath = await writeImportFile(importFixture());
await expect(service().acceptSuccessor({ filePath })).rejects.toBeInstanceOf(PrdNotFoundError);
});
});
// ── No second writer: no code path reads exported Markdown back into authority ─
describe('no-second-writer invariant (source-level)', () => {
// Resolve the package source dir whether vitest runs from the package root
// (turbo/pnpm test) or from the worktree root.
function resolveSrcDir(): string {
const candidates = [path.resolve('src'), path.resolve('packages/prdy/src')];
return candidates.find((dir) => existsSync(path.join(dir, 'service.ts'))) ?? candidates[0]!;
}
const srcDir = resolveSrcDir();
const sourceFiles = [
'cli.ts',
'index.ts',
'prd.ts',
'service.ts',
'templates.ts',
'types.ts',
'wizard.ts',
];
it('no source file in @mosaicstack/prdy reads a .md file', async () => {
for (const file of sourceFiles) {
const text = await readFile(path.join(srcDir, file), 'utf8');
const readLines = text
.split('\n')
.map((line) => line.trim())
.filter((line) => /readFile|readFileSync|createReadStream/.test(line));
for (const line of readLines) {
expect(line.includes('.md'), `${file} reads a Markdown file: ${line}`).toBe(false);
}
}
});
it('the mosaic prdy/mission adapters never read a .md file', async () => {
const adapterDir = path.resolve(srcDir, '..', '..', 'mosaic', 'src', 'commands');
for (const file of ['prdy.ts', 'mission.ts']) {
const text = await readFile(path.join(adapterDir, file), 'utf8');
expect(text.includes("'.md'") || text.includes('.md`'), `${file} references a .md path`).toBe(
false,
);
}
});
});
-379
View File
@@ -1,379 +0,0 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import yaml from 'js-yaml';
import { createPrd, listPrds, parsePrdDocument, prdDirectory, savePrd } from './prd.js';
import type {
PrdCreateInput,
PrdDocument,
PrdExportInput,
PrdExportResult,
PrdImportInput,
PrdImportResult,
PrdLinkMissionInput,
PrdMissionLinkage,
PrdPlanForMissionInput,
PrdServiceOptions,
PrdUpdateInput,
} from './types.js';
/**
* PrdService is the SINGLE authority surface for PRD documents.
*
* Every mutation path (CLI wizard, `mosaic mission --plan`, import) routes
* through this service; the YAML store under `docs/prdy/` is the authority and
* exported Markdown is a generated view that no code path reads back.
*/
// ── Typed errors ───────────────────────────────────────────────────────────────
export class PrdError extends Error {
constructor(
message: string,
readonly code: string,
) {
super(message);
this.name = 'PrdError';
}
}
export class PrdNotFoundError extends PrdError {
constructor(message: string) {
super(message, 'PRD_NOT_FOUND');
this.name = 'PrdNotFoundError';
}
}
export class PrdUpdateError extends PrdError {
constructor(message: string) {
super(message, 'PRD_UPDATE_INVALID');
this.name = 'PrdUpdateError';
}
}
/** Structural refusal: the import payload failed schema validation. Nothing is written. */
export class PrdImportInvalidError extends PrdError {
constructor(
message: string,
readonly issues?: string,
) {
super(message, 'PRD_IMPORT_INVALID');
this.name = 'PrdImportInvalidError';
}
}
/**
* Conflict refusal: an existing PRD shares the imported id but the content
* diverges. Carries a PROPOSED successor (existing version + 1) that is only
* persisted via an explicit {@link PrdService.acceptSuccessor} call import
* never overwrites and never merges.
*/
export class PrdImportConflictError extends PrdError {
constructor(
message: string,
readonly existing: PrdDocument,
readonly proposal: PrdDocument,
) {
super(message, 'PRD_IMPORT_CONFLICT');
this.name = 'PrdImportConflictError';
}
}
// ── Service ────────────────────────────────────────────────────────────────────
/** The generated-view label carried by every Markdown export. */
export const PRD_GENERATED_VIEW_LABEL = 'generated view — do not edit';
export class PrdService {
private readonly projectPath: string;
constructor(options: PrdServiceOptions) {
this.projectPath = options.projectPath;
}
/** Create a new PRD (version 1, draft) in the authority store. */
async create(input: PrdCreateInput): Promise<PrdDocument> {
return createPrd({
name: input.name,
projectPath: this.projectPath,
template: input.template,
interactive: false,
});
}
/** Read a PRD by id, or the most recently updated one. */
async get(id?: string): Promise<PrdDocument> {
const documents = await listPrds(this.projectPath);
if (id === undefined) {
const latest = documents[0];
if (latest === undefined) {
throw new PrdNotFoundError(`No PRD documents found under docs/prdy/ for this project`);
}
return latest;
}
const match = documents.find((doc) => doc.id === id);
if (match === undefined) {
throw new PrdNotFoundError(`PRD id not found: ${id}`);
}
return match;
}
/** List all PRDs in the authority store (most recently updated first). */
async list(): Promise<PrdDocument[]> {
return listPrds(this.projectPath);
}
/**
* Apply section field patches and bump the content version.
* Linkage entries are preserved; linkage writes do NOT bump the version.
*/
async update(input: PrdUpdateInput): Promise<PrdDocument> {
const doc = await this.get(input.id);
for (const patch of input.sections) {
const section = doc.sections.find((candidate) => candidate.id === patch.id);
if (section === undefined) {
throw new PrdUpdateError(`Unknown section id: ${patch.id}`);
}
for (const [field, value] of Object.entries(patch.fields)) {
if (!(field in section.fields)) {
throw new PrdUpdateError(`Unknown field "${field}" on section "${patch.id}"`);
}
section.fields[field] = value;
}
}
doc.version += 1;
doc.updatedAt = new Date().toISOString();
await savePrd(doc);
return doc;
}
/**
* Record (or refresh) a mission PRD linkage on the PRD document.
* Persisted in the YAML authority, so it survives restarts.
*/
async linkMission(input: PrdLinkMissionInput): Promise<PrdDocument> {
const doc = await this.get(input.prdId);
return this.applyLinkage(doc, input);
}
/** Read back the mission linkages recorded on a PRD. */
async listMissionLinks(prdId?: string): Promise<PrdMissionLinkage[]> {
const doc = await this.get(prdId);
return doc.missions;
}
/**
* Mission planning path: create a PRD for a mission AND persist the
* missionPRD linkage in a single authority write.
*/
async planForMission(input: PrdPlanForMissionInput): Promise<PrdDocument> {
const doc = await this.create({ name: input.name, template: input.template });
return this.applyLinkage(doc, {
prdId: doc.id,
missionId: input.missionId,
missionVersion: input.missionVersion,
requirementIds: input.requirementIds,
});
}
/**
* Render the PRD to a Markdown GENERATED VIEW.
*
* The output carries source identity (PRD id + version + generated-view
* label). It is written under `docs/prdy/<id>.md` and is NEVER read back:
* the authority store only loads `.yaml`/`.yml` files, and no code path in
* this package parses the exported Markdown.
*/
async exportMarkdown(input?: PrdExportInput): Promise<PrdExportResult> {
const doc = await this.get(input?.id);
const content = renderMarkdown(doc);
const filePath = input?.outPath ?? path.join(prdDirectory(doc.projectPath), `${doc.id}.md`);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, 'utf8');
return { filePath, content };
}
/**
* Import a YAML PRD document.
*
* Structural validation (zod) happens BEFORE anything is proposed or
* written. A structurally-valid import is persisted as `draft` validity is
* NOT approval. If an existing PRD shares the id with divergent content, a
* typed {@link PrdImportConflictError} is thrown carrying a proposed
* successor; the original authority document is left byte-identical on disk.
*/
async importDocument(input: PrdImportInput): Promise<PrdImportResult> {
const incoming = await this.readImportFile(input.filePath);
const existing = (await listPrds(this.projectPath)).find((doc) => doc.id === incoming.id);
if (existing === undefined) {
const document = this.buildImportedDocument(incoming);
await savePrd(document);
return { kind: 'created', document };
}
if (canonicalCore(existing) === canonicalCore(incoming)) {
return { kind: 'identical', document: existing };
}
throw new PrdImportConflictError(
`PRD id "${incoming.id}" already exists with divergent content — refusing to overwrite. ` +
`Proposed successor: version ${existing.version + 1} (draft). ` +
`Accept explicitly with acceptSuccessor().`,
existing,
this.buildSuccessor(existing, incoming),
);
}
/**
* Explicitly accept a conflicted import as a successor version of the
* existing PRD. Re-validates the source file before writing; the successor
* is persisted with status `draft` (acceptance of the import is not approval
* of the PRD) and the existing mission linkages are carried forward.
*/
async acceptSuccessor(input: PrdImportInput): Promise<PrdDocument> {
const incoming = await this.readImportFile(input.filePath);
const existing = (await listPrds(this.projectPath)).find((doc) => doc.id === incoming.id);
if (existing === undefined) {
throw new PrdNotFoundError(
`No existing PRD with id "${incoming.id}" — use importDocument to create it`,
);
}
const successor = this.buildSuccessor(existing, incoming);
await savePrd(successor);
return successor;
}
// ── internals ──────────────────────────────────────────────────────────────
private async applyLinkage(doc: PrdDocument, input: PrdLinkMissionInput): Promise<PrdDocument> {
const entry: PrdMissionLinkage = {
missionId: input.missionId,
missionVersion: input.missionVersion,
prdVersion: doc.version,
requirementIds: input.requirementIds ?? [],
linkedAt: new Date().toISOString(),
};
// One entry per mission: refresh in place if the mission is already linked.
const index = doc.missions.findIndex((m) => m.missionId === entry.missionId);
if (index === -1) {
doc.missions.push(entry);
} else {
doc.missions[index] = entry;
}
// Linkage is mission-side metadata, not a content revision: bump the
// timestamp only so ids/versions stay stable for consumers.
doc.updatedAt = new Date().toISOString();
await savePrd(doc);
return doc;
}
private async readImportFile(filePath: string): Promise<PrdDocument> {
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf8');
} catch (error) {
throw new PrdImportInvalidError(`Cannot read import file ${filePath}: ${String(error)}`);
}
let parsed: unknown;
try {
parsed = yaml.load(raw);
} catch (error) {
throw new PrdImportInvalidError(`Import file is not valid YAML: ${String(error)}`);
}
try {
return parsePrdDocument(parsed);
} catch (error) {
throw new PrdImportInvalidError(
`Import file failed PRD schema validation: ${filePath}`,
error instanceof Error ? error.message : String(error),
);
}
}
private buildImportedDocument(incoming: PrdDocument): PrdDocument {
const now = new Date().toISOString();
return {
...incoming,
// The import lands in THIS project's authority store.
projectPath: this.projectPath,
// A structurally-valid import is not thereby approved.
status: 'draft',
version: 1,
missions: [],
createdAt: now,
updatedAt: now,
};
}
private buildSuccessor(existing: PrdDocument, incoming: PrdDocument): PrdDocument {
return {
...incoming,
id: existing.id,
projectPath: existing.projectPath,
status: 'draft',
version: existing.version + 1,
missions: existing.missions,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
}
}
// ── Markdown rendering (generated view) ───────────────────────────────────────
function canonicalCore(doc: PrdDocument): string {
return JSON.stringify([doc.title, doc.template, doc.sections]);
}
function renderMarkdown(doc: PrdDocument): string {
const lines: string[] = [
'<!--',
`${PRD_GENERATED_VIEW_LABEL}`,
`source-of-truth: docs/prdy/${doc.id}.yaml (YAML authority)`,
`prd-id: ${doc.id}`,
`prd-version: ${doc.version}`,
`generated-at: ${new Date().toISOString()}`,
'-->',
'',
`# ${doc.title}`,
'',
`**Status:** ${doc.status} · **Version:** ${doc.version} · **Template:** ${doc.template}`,
'',
];
if (doc.missions.length > 0) {
lines.push('## Mission Linkage', '');
for (const mission of doc.missions) {
const requirements =
mission.requirementIds.length > 0 ? mission.requirementIds.join(', ') : 'none selected';
lines.push(
`- mission \`${mission.missionId}\` @ version \`${mission.missionVersion}\`` +
` (linked at PRD v${mission.prdVersion}) — requirements: ${requirements}`,
);
}
lines.push('');
}
for (const section of doc.sections) {
lines.push(`## ${section.title}`, '');
for (const [field, value] of Object.entries(section.fields)) {
lines.push(`### ${field}`, '', value.trim().length > 0 ? value : '_Not set_.', '');
}
}
lines.push('---', '', `_End of generated view for ${doc.id} v${doc.version}._`, '');
return lines.join('\n');
}
-75
View File
@@ -19,31 +19,13 @@ export interface PrdSection {
fields: Record<string, string>;
}
/**
* Mission PRD linkage recorded on the PRD document (the YAML authority).
*
* `missionVersion` is the mission-side revision marker available to the CLI
* (the gateway exposes `updatedAt` for missions there is no numeric mission
* version yet). `prdVersion` snapshots the PRD content version at link time.
*/
export interface PrdMissionLinkage {
missionId: string;
missionVersion: string;
prdVersion: number;
requirementIds: string[];
linkedAt: string;
}
export interface PrdDocument {
id: string;
title: string;
status: PrdStatus;
projectPath: string;
template: string;
/** Content revision counter. Bumped by updates and accepted imports. */
version: number;
sections: PrdSection[];
missions: PrdMissionLinkage[];
createdAt: string;
updatedAt: string;
}
@@ -54,60 +36,3 @@ export interface CreatePrdOptions {
template?: string;
interactive?: boolean;
}
// ── PrdService surface (single authority entry point) ─────────────────────────
export interface PrdServiceOptions {
projectPath: string;
}
export interface PrdCreateInput {
name: string;
template?: string;
}
export interface PrdSectionPatch {
id: string;
fields: Record<string, string>;
}
export interface PrdUpdateInput {
/** Defaults to the most recently updated PRD. */
id?: string;
sections: PrdSectionPatch[];
}
export interface PrdLinkMissionInput {
/** Defaults to the most recently updated PRD. */
prdId?: string;
missionId: string;
missionVersion: string;
requirementIds?: string[];
}
export interface PrdPlanForMissionInput extends PrdLinkMissionInput {
name: string;
template?: string;
}
export interface PrdExportInput {
/** Defaults to the most recently updated PRD. */
id?: string;
/** Override the generated-view output path. */
outPath?: string;
}
export interface PrdExportResult {
filePath: string;
content: string;
}
/** Discriminated result of a non-conflicting import. */
export type PrdImportResult =
| { kind: 'created'; document: PrdDocument }
| { kind: 'identical'; document: PrdDocument };
export interface PrdImportInput {
/** Path to a YAML-serialized PRD document (NOT the generated Markdown view). */
filePath: string;
}
+32 -43
View File
@@ -2,8 +2,8 @@ import path from 'node:path';
import { cancel, intro, isCancel, outro, select, text } from '@clack/prompts';
import { PrdService } from './service.js';
import type { CreatePrdOptions, PrdDocument, PrdSectionPatch } from './types.js';
import { createPrd, savePrd } from './prd.js';
import type { CreatePrdOptions, PrdDocument } from './types.js';
interface WizardAnswers {
goals: string;
@@ -11,41 +11,20 @@ interface WizardAnswers {
milestones: string;
}
/**
* Translate wizard answers into section patches using the same keyword
* matching the wizard always used (first section whose id contains the
* keyword, then first field whose name contains it, else first field).
*/
function buildWizardPatches(doc: PrdDocument, answers: WizardAnswers): PrdSectionPatch[] {
const bySection = new Map<string, PrdSectionPatch>();
function updateSectionField(doc: PrdDocument, sectionKeyword: string, value: string): void {
const section = doc.sections.find((candidate) => candidate.id.includes(sectionKeyword));
const add = (keyword: string, value: string): void => {
const section = doc.sections.find((candidate) => candidate.id.includes(keyword));
if (section === undefined) {
return;
}
if (section === undefined) {
return;
}
const fieldName =
Object.keys(section.fields).find((field) => field.toLowerCase().includes(keyword)) ??
Object.keys(section.fields)[0];
const fieldName =
Object.keys(section.fields).find((field) => field.toLowerCase().includes(sectionKeyword)) ??
Object.keys(section.fields)[0];
if (fieldName === undefined || section.fields[fieldName] === value) {
return;
}
const existing = bySection.get(section.id);
if (existing === undefined) {
bySection.set(section.id, { id: section.id, fields: { [fieldName]: value } });
} else {
existing.fields[fieldName] = value;
}
};
add('goal', answers.goals);
add('constraint', answers.constraints);
add('milestone', answers.milestones);
return [...bySection.values()];
if (fieldName !== undefined) {
section.fields[fieldName] = value;
}
}
async function promptText(message: string, initialValue = ''): Promise<string> {
@@ -84,10 +63,15 @@ async function promptTemplate(template?: string): Promise<string> {
return choice;
}
/**
* Interactive PRD wizard. All writes go through PrdService the wizard is a
* prompt layer, never a second writer path.
*/
function applyWizardAnswers(doc: PrdDocument, answers: WizardAnswers): PrdDocument {
updateSectionField(doc, 'goal', answers.goals);
updateSectionField(doc, 'constraint', answers.constraints);
updateSectionField(doc, 'milestone', answers.milestones);
doc.updatedAt = new Date().toISOString();
return doc;
}
export async function runPrdWizard(options: CreatePrdOptions): Promise<PrdDocument> {
intro('Mosaic PRD wizard');
@@ -98,15 +82,20 @@ export async function runPrdWizard(options: CreatePrdOptions): Promise<PrdDocume
const constraints = await promptText('Key constraints');
const milestones = await promptText('Planned milestones');
const service = new PrdService({ projectPath: options.projectPath });
const doc = await service.create({
const doc = await createPrd({
...options,
name,
template,
interactive: true,
});
const patches = buildWizardPatches(doc, { goals, constraints, milestones });
const updated =
patches.length > 0 ? await service.update({ id: doc.id, sections: patches }) : doc;
const updated = applyWizardAnswers(doc, {
goals,
constraints,
milestones,
});
await savePrd(updated);
outro(`PRD created: ${path.join(updated.projectPath, 'docs', 'prdy', `${updated.id}.yaml`)}`);
-63
View File
@@ -1,63 +0,0 @@
# @mosaicstack/quality-rails
Quality-rails scaffolding and — since RI-3-002 (RI-N4) — the **typed
quality-rails evaluator**: the single authoritative producer of check verdicts
for the checks it owns.
## Evaluator (RI-N4)
Every verdict is typed and fail-closed:
```
{ status: 'passed' | 'failed' | 'blocked' | 'error' | 'not-applicable',
checkId, checkVersion, subject, reason }
```
Missing implementations, missing inputs, unknown check ids, process errors,
timeouts, and malformed probe output can never become `passed` or an
unqualified skip — they surface as `blocked`/`error` with a reason (vocabulary
mirrors MACP's `GateStatus` discipline).
- Check definitions live as **data** with a version and a sha256 content
digest (`definitionDigest`); every recorded verdict names the definition
version that produced it.
- Check sets are selected **per subject kind** (`node`, `python`, `rust`,
`monorepo`, `unknown`) via the versioned, digested check-set policy — this
repository (a `monorepo` subject) does not share the node template's file
list.
- Shell probes stay **thin adapters**: the TS evaluator invokes them and owns
the verdict parsing (e.g. QC-20's planted-commit probe).
### Owned checks
| check id | canonical check | mechanism |
| --------------------------- | --------------- | ---------------------------------------------------------------------- |
| `qc-19-rails-files-present` | QC-19 | typed absorption of the former presence-only `check`/`doctor` loop |
| `qc-20-enforcement-verify` | QC-20 | thin shell adapter (framework `verify.sh`); verdict parsing owned here |
The canonical check ids QC-1..QC-21 and their dispositions are defined in
`docs/release-integrity/probe-inventory.md` (the RI-3-001 inventory — the
evaluator's input, not its output).
### Usage
```sh
# CLI (same typed report as the programmatic API)
node dist/cli.js quality-rails evaluate --project <path> [--probe-path <verify.sh>] [--json]
node dist/cli.js quality-rails check --project <path> [--json] # QC-19 only, fail-closed exit
```
```ts
import { evaluateSubject } from '@mosaicstack/quality-rails';
const report = await evaluateSubject({ subjectPath: '/path/to/project' });
// report.state: 'passed' | 'failed' | 'blocked' | 'error'
```
`pnpm verify:release` invokes this evaluator as its `quality-rails` stage
(canonical-only stage, QC-19 on the monorepo subject).
## Scaffolding (pre-existing)
`init` scaffolds rails files per detected kind/profile; `doctor` is advisory
and reports typed states.
@@ -1,194 +0,0 @@
import { mkdir, mkdtemp, writeFile, chmod } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createQualityRailsCli } from './cli.js';
import { QC_19_RAILS_FILES_PRESENT } from './evaluator/definitions.js';
import { evaluateSubject } from './evaluator/runner.js';
import type { EvaluationReport } from './evaluator/types.js';
// CLI ↔ programmatic contract (RI-3-002): the same subject must produce the
// same typed verdicts through every entry point the card adds — the
// `evaluate`/`check` CLI surfaces and the `evaluateSubject` API.
async function makeTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), 'qr-cli-'));
}
async function scaffoldNodeFixture(skip: string[] = []): Promise<string> {
const dir = await makeTempDir();
await writeFile(join(dir, 'package.json'), '{}\n', 'utf8');
for (const relativePath of [
'.eslintrc',
'biome.json',
'.githooks/pre-commit',
'PR-CHECKLIST.md',
]) {
if (skip.includes(relativePath)) continue;
await mkdir(join(dir, relativePath, '..'), { recursive: true });
await writeFile(join(dir, relativePath), 'fixture\n', 'utf8');
}
return dir;
}
async function makePassingProbe(dir: string): Promise<string> {
const scriptPath = join(dir, 'probe-pass.sh');
await writeFile(
scriptPath,
[
'#!/bin/bash',
'echo "✅ PASS: Type errors blocked"',
'echo "✅ PASS: Lint errors blocked"',
'echo "Verification Summary"',
'exit 0',
].join('\n') + '\n',
'utf8',
);
await chmod(scriptPath, 0o755);
return scriptPath;
}
describe('CLI entry points vs the programmatic evaluator', () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let previousExitCode: string | number | undefined;
beforeEach(() => {
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
previousExitCode = process.exitCode ?? undefined;
});
afterEach(() => {
logSpy.mockRestore();
process.exitCode = previousExitCode;
});
it('evaluate --json produces the SAME typed report as evaluateSubject (full check set + probe)', async () => {
const dir = await scaffoldNodeFixture();
const probePath = await makePassingProbe(dir);
const programmatic = await evaluateSubject({
subjectPath: dir,
inputs: { 'qc-20-enforcement-verify': { probePath } },
});
const program = createQualityRailsCli();
await program.parseAsync([
'node',
'cli.js',
'quality-rails',
'evaluate',
'--project',
dir,
'--probe-path',
probePath,
'--json',
]);
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
const cliReport = JSON.parse(printed) as EvaluationReport;
expect(cliReport).toEqual(programmatic);
expect(cliReport.state).toBe('passed');
expect(process.exitCode).toBe(0);
});
it('check --json produces the SAME QC-19 verdict as evaluateSubject (absorbed loop)', async () => {
const dir = await scaffoldNodeFixture(['biome.json', '.githooks/pre-commit']);
const programmatic = await evaluateSubject({
subjectPath: dir,
checkIds: [QC_19_RAILS_FILES_PRESENT.id],
});
expect(programmatic.state).toBe('failed');
const program = createQualityRailsCli();
await program.parseAsync([
'node',
'cli.js',
'quality-rails',
'check',
'--project',
dir,
'--json',
]);
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
const cliReport = JSON.parse(printed) as EvaluationReport;
expect(cliReport).toEqual(programmatic);
expect(process.exitCode).toBe(1);
});
it('check on a complete subject exits 0 with a passed verdict', async () => {
const dir = await scaffoldNodeFixture();
const program = createQualityRailsCli();
await program.parseAsync([
'node',
'cli.js',
'quality-rails',
'check',
'--project',
dir,
'--json',
]);
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
const cliReport = JSON.parse(printed) as EvaluationReport;
expect(cliReport.state).toBe('passed');
expect(process.exitCode).toBe(0);
});
it('evaluate with an unknown check id exits 1 and reports error, never passed', async () => {
const dir = await scaffoldNodeFixture();
const program = createQualityRailsCli();
await program.parseAsync([
'node',
'cli.js',
'quality-rails',
'evaluate',
'--project',
dir,
'--check',
'qc-99-bogus',
'--json',
]);
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
const cliReport = JSON.parse(printed) as EvaluationReport;
expect(cliReport.results).toHaveLength(1);
const first = cliReport.results[0];
expect(first?.status).toBe('error');
expect(first?.reason).toContain('unknown check id');
expect(process.exitCode).toBe(1);
});
it('evaluate on a scaffold subject without --probe-path stays fail-closed (blocked, exit 1)', async () => {
const dir = await scaffoldNodeFixture();
const program = createQualityRailsCli();
await program.parseAsync([
'node',
'cli.js',
'quality-rails',
'evaluate',
'--project',
dir,
'--json',
]);
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
const cliReport = JSON.parse(printed) as EvaluationReport;
const qc20 = cliReport.results.find((r) => r.checkId === 'qc-20-enforcement-verify');
expect(qc20).toBeDefined();
expect(qc20?.status).toBe('blocked');
expect(qc20?.reason).toContain('probePath');
expect(cliReport.state).toBe('blocked');
expect(process.exitCode).toBe(1);
});
it('doctor stays advisory (no nonzero exit) but reports TYPED states, including blocked', async () => {
const dir = await scaffoldNodeFixture();
const program = createQualityRailsCli();
await program.parseAsync(['node', 'cli.js', 'quality-rails', 'doctor', '--project', dir]);
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
expect(printed).toContain('blocked: qc-20-enforcement-verify');
expect(process.exitCode ?? 0).toBe(0);
});
});
+54 -81
View File
@@ -1,3 +1,5 @@
import { constants } from 'node:fs';
import { access } from 'node:fs/promises';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -6,12 +8,18 @@ import { Command } from 'commander';
import { detectProjectKind } from './detect.js';
import { scaffoldQualityRails } from './scaffolder.js';
import type { ProjectKind, QualityProfile, RailsConfig } from './types.js';
import { QC_19_RAILS_FILES_PRESENT } from './evaluator/definitions.js';
import { evaluateSubject } from './evaluator/runner.js';
import type { EvaluationReport } from './evaluator/types.js';
const VALID_PROFILES: readonly QualityProfile[] = ['strict', 'standard', 'minimal'];
async function fileExists(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.F_OK);
return true;
} catch {
return false;
}
}
function parseProfile(rawProfile: string): QualityProfile {
if (VALID_PROFILES.includes(rawProfile as QualityProfile)) {
return rawProfile as QualityProfile;
@@ -51,6 +59,22 @@ function defaultFormatters(kind: ProjectKind): string[] {
return [];
}
function expectedFilesForKind(kind: ProjectKind): string[] {
if (kind === 'node') {
return ['.eslintrc', 'biome.json', '.githooks/pre-commit', 'PR-CHECKLIST.md'];
}
if (kind === 'python') {
return ['pyproject.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'];
}
if (kind === 'rust') {
return ['rustfmt.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'];
}
return ['.githooks/pre-commit', 'PR-CHECKLIST.md'];
}
function printScaffoldResult(
config: RailsConfig,
filesWritten: string[],
@@ -82,20 +106,6 @@ function printScaffoldResult(
}
}
function printEvaluationReport(report: EvaluationReport): void {
console.log(
`[quality-rails] evaluation for ${report.subject.path} (kind=${report.subject.kind}, check-set v${report.checkSetVersion})`,
);
for (const result of report.results) {
const reason = result.reason === undefined ? '' : `${result.reason}`;
const digest = report.definitionDigests[result.checkId] ?? 'no digest';
console.log(
` - ${result.status}: ${result.checkId} (v${result.checkVersion} [${digest}])${reason}`,
);
}
console.log(`[quality-rails] aggregate: ${report.state}`);
}
/**
* Register quality-rails subcommands on an existing Commander program.
* This avoids cross-package Commander version mismatches by using the
@@ -138,93 +148,56 @@ function buildQualityRailsCommand(qualityRails: Command): void {
printScaffoldResult(config, result.filesWritten, result.warnings, result.commandsToRun);
});
// `check` (QC-19) is ABSORBED by the RI-N4 evaluator: the presence loop
// that lived here is now the versioned, digested, typed check definition
// `qc-19-rails-files-present`. The CLI keeps its human surface (missing
// files listed, exit 1) and gains `--json` for the typed verdicts. Exit
// code is fail-closed: any non-green aggregate (failed/blocked/error) is 1.
qualityRails
.command('check')
.requiredOption('--project <path>', 'Project path')
.option('--json', 'print the typed evaluation report as JSON')
.action(async (options: { project: string; json?: boolean }) => {
.action(async (options: { project: string }) => {
const projectPath = resolve(options.project);
const report = await evaluateSubject({
subjectPath: projectPath,
checkIds: [QC_19_RAILS_FILES_PRESENT.id],
});
const kind = await detectProjectKind(projectPath);
const expected = expectedFilesForKind(kind);
const missing: string[] = [];
if (options.json) {
console.log(JSON.stringify(report));
} else {
printEvaluationReport(report);
for (const relativePath of expected) {
const exists = await fileExists(resolve(projectPath, relativePath));
if (!exists) {
missing.push(relativePath);
}
}
process.exitCode = report.state === 'passed' ? 0 : 1;
if (missing.length > 0) {
console.error('[quality-rails] missing files:');
for (const relativePath of missing) {
console.error(` - ${relativePath}`);
}
process.exitCode = 1;
return;
}
console.log(`[quality-rails] all expected files present for ${kind} project`);
});
// `doctor` (QC-19) stays advisory (documented contract: a doctor that
// cannot fail), but now reports TYPED states — a blocked or failing rail is
// visible instead of silently printed as `ok`/`missing`.
qualityRails
.command('doctor')
.requiredOption('--project <path>', 'Project path')
.action(async (options: { project: string }) => {
const projectPath = resolve(options.project);
const report = await evaluateSubject({ subjectPath: projectPath });
const kind = await detectProjectKind(projectPath);
const expected = expectedFilesForKind(kind);
console.log(`[quality-rails] doctor for ${projectPath}`);
console.log(`detected project kind: ${report.subject.kind}`);
for (const result of report.results) {
const reason = result.reason === undefined ? '' : `${result.reason}`;
console.log(` - ${result.status}: ${result.checkId}${reason}`);
console.log(`detected project kind: ${kind}`);
for (const relativePath of expected) {
const exists = await fileExists(resolve(projectPath, relativePath));
console.log(` - ${exists ? 'ok' : 'missing'}: ${relativePath}`);
}
if (report.subject.kind === 'unknown') {
if (kind === 'unknown') {
console.log(
'recommendation: add package.json, pyproject.toml, or Cargo.toml for better defaults.',
);
}
});
// `evaluate` is the canonical RI-N4 evaluator entry point: typed verdicts
// for the subject's full per-kind check set, same results as the
// programmatic API (evaluateSubject).
qualityRails
.command('evaluate')
.description('Run the typed quality-rails evaluator against a subject project')
.requiredOption('--project <path>', 'Project path')
.option('--check <id...>', 'restrict evaluation to these check ids')
.option(
'--probe-path <path>',
'path to the QC-20 behavioral probe script (framework verify.sh)',
)
.option('--json', 'print the typed evaluation report as JSON')
.action(
async (options: {
project: string;
check?: string[];
probePath?: string;
json?: boolean;
}) => {
const projectPath = resolve(options.project);
const report = await evaluateSubject({
subjectPath: projectPath,
checkIds: options.check,
inputs: options.probePath
? { 'qc-20-enforcement-verify': { probePath: options.probePath } }
: undefined,
});
if (options.json) {
console.log(JSON.stringify(report));
} else {
printEvaluationReport(report);
}
process.exitCode = report.state === 'passed' ? 0 : 1;
},
);
}
export async function runQualityRailsCli(argv: string[] = process.argv): Promise<void> {
@@ -1,67 +0,0 @@
import { spawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import type { AdapterOutcome, AdapterRequest, ProcessAdapter } from './types.js';
/**
* Default thin process adapter (spawn-based). Runs a command to completion with
* a hard timeout and reports exit code + captured output it owns NO verdict
* logic. Interpreting the outcome is always the check implementation's job.
*/
export function createSpawnProcessAdapter(): ProcessAdapter {
return {
run(request: AdapterRequest): Promise<AdapterOutcome> {
return new Promise((resolve) => {
let child: ChildProcess;
try {
child = spawn(request.file, request.args, {
cwd: request.cwd,
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (error) {
resolve({
ok: false,
kind: 'spawn-error',
message: error instanceof Error ? error.message : String(error),
});
return;
}
let stdout = '';
let stderr = '';
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill('SIGKILL');
resolve({
ok: false,
kind: 'timeout',
message: `process timed out after ${request.timeoutMs}ms: ${request.file}`,
});
}, request.timeoutMs);
const settle = (outcome: AdapterOutcome): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(outcome);
};
child.stdout?.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
});
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
child.on('error', (error: Error) => {
settle({ ok: false, kind: 'spawn-error', message: error.message });
});
child.on('close', (code: number | null) => {
settle({ ok: true, exitCode: code, stdout, stderr });
});
});
},
};
}
@@ -1,258 +0,0 @@
import { constants } from 'node:fs';
import { access } from 'node:fs/promises';
import { isAbsolute, resolve } from 'node:path';
import { digestOfPolicy, digestOfSpec } from './digest.js';
import type {
CheckContext,
CheckDefinition,
CheckDefinitionSpec,
CheckOutcome,
CheckSetPolicy,
CheckSetPolicySpec,
SubjectKind,
} from './types.js';
// Check definitions for the RI-N4 evaluator (card RI-3-002). Each definition is
// DATA with a version and a content digest (see digest.ts); the executable
// half is attached via defineCheck. Check-set SELECTION is per subject kind
// (probe-inventory gap 7): this monorepo does not match the node template's
// file list, so the QC-19 definition carries a distinct file set for the
// `monorepo` subject kind and the policy selects checks per kind.
export function defineCheck(
spec: CheckDefinitionSpec,
evaluate: (ctx: CheckContext) => Promise<CheckOutcome>,
): CheckDefinition {
return { ...spec, definitionDigest: digestOfSpec(spec), evaluate };
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.F_OK);
return true;
} catch {
return false;
}
}
// ─── QC-19: downstream rails presence ────────────────────────────────────────
//
// Typed absorption of the former presence-only `quality-rails check` loop in
// cli.ts. The scaffold-kind file lists below are carried over VERBATIM so the
// evaluator's typed verdicts are parity-equivalent with the presence loop on
// the same fixture; the `monorepo` list is new (per-subject check sets).
const qc19Spec: CheckDefinitionSpec = {
id: 'qc-19-rails-files-present',
version: '1.0.0',
canonicalCheck: 'QC-19',
description:
'The subject still carries its quality-rails files. Typed absorption of the former presence-only check loop; presence is necessary, not sufficient (RI-N4).',
appliesTo: ['node', 'python', 'rust', 'monorepo', 'unknown'],
params: {
expectedFilesByKind: {
node: ['.eslintrc', 'biome.json', '.githooks/pre-commit', 'PR-CHECKLIST.md'],
python: ['pyproject.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'],
rust: ['rustfmt.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'],
monorepo: [
'.husky/pre-commit',
'.husky/pre-push',
'eslint.config.mjs',
'.prettierrc',
'.lintstagedrc',
],
unknown: ['.githooks/pre-commit', 'PR-CHECKLIST.md'],
},
},
};
async function evaluateQc19(ctx: CheckContext): Promise<CheckOutcome> {
const byKind = ctx.params['expectedFilesByKind'] as Record<string, readonly string[]> | undefined;
if (byKind === undefined) {
return { status: 'error', reason: 'definition params missing expectedFilesByKind' };
}
const expected = byKind[ctx.subject.kind];
if (expected === undefined) {
// Fail-closed: an undefined file set for a declared subject kind is a
// definition gap, never a green outcome.
return {
status: 'blocked',
reason: `no expected-file set defined for subject kind '${ctx.subject.kind}'`,
};
}
const missing: string[] = [];
for (const relativePath of expected) {
if (!(await fileExists(resolve(ctx.subject.path, relativePath)))) {
missing.push(relativePath);
}
}
if (missing.length > 0) {
return {
status: 'failed',
reason: `missing rails files (${ctx.subject.kind}): ${missing.join(', ')}`,
};
}
return { status: 'passed' };
}
// ─── QC-20: downstream enforcement verification (behavioral probe) ──────────
//
// The planted-commit behavioral probe (framework tools/quality/scripts/verify.sh)
// stays a THIN SHELL ADAPTER: the TS evaluator invokes it and OWNS the verdict
// parsing (RI-N4: grep-on-output verdict logic moves into the typed evaluator).
// Probe contract (verify.sh): exit 0 ⇔ every sub-probe passed, exit 1 ⇔ at
// least one sub-probe failed; sub-probe verdicts appear as `PASS:` / `FAIL:`
// marker lines and the script always prints a `Verification Summary` section.
// Any deviation from that contract (other exit codes, unparseable output,
// missing probe, process failure, timeout) is `error`/`blocked` — never
// `passed`.
const qc20Spec: CheckDefinitionSpec = {
id: 'qc-20-enforcement-verify',
version: '1.0.0',
canonicalCheck: 'QC-20',
description:
'The behavioral planted-commit probe runs against the subject and every sub-probe blocks as intended. The shell probe is a thin adapter; verdict parsing is owned by this evaluator.',
appliesTo: ['node', 'python', 'rust', 'unknown'],
params: {
command: 'bash',
timeoutMs: 120_000,
passMarker: 'PASS:',
failMarker: 'FAIL:',
summaryMarker: 'Verification Summary',
},
};
function linesWith(text: string, marker: string): string[] {
return text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.includes(marker));
}
async function evaluateQc20(ctx: CheckContext): Promise<CheckOutcome> {
const rawProbePath = ctx.inputs['probePath'];
if (typeof rawProbePath !== 'string' || rawProbePath.trim().length === 0) {
return {
status: 'blocked',
reason:
'missing input: probePath — the behavioral probe script must be provided (e.g. the framework verify.sh)',
};
}
const probePath = isAbsolute(rawProbePath)
? rawProbePath
: resolve(ctx.subject.path, rawProbePath);
if (!(await fileExists(probePath))) {
return { status: 'blocked', reason: `probe script not found: ${probePath}` };
}
const command = typeof ctx.params['command'] === 'string' ? ctx.params['command'] : 'bash';
const timeoutMs = typeof ctx.params['timeoutMs'] === 'number' ? ctx.params['timeoutMs'] : 120_000;
const passMarker =
typeof ctx.params['passMarker'] === 'string' ? ctx.params['passMarker'] : 'PASS:';
const failMarker =
typeof ctx.params['failMarker'] === 'string' ? ctx.params['failMarker'] : 'FAIL:';
const summaryMarker =
typeof ctx.params['summaryMarker'] === 'string'
? ctx.params['summaryMarker']
: 'Verification Summary';
const outcome = await ctx.adapter.run({
file: command,
args: [probePath],
cwd: ctx.subject.path,
timeoutMs,
});
if (!outcome.ok) {
// Process error or timeout: the probe never produced a trustworthy result.
return {
status: 'error',
reason: `probe process ${outcome.kind}: ${outcome.message}`,
};
}
const output = `${outcome.stdout}\n${outcome.stderr}`;
const failLines = linesWith(output, failMarker);
const passLines = linesWith(output, passMarker);
if (outcome.exitCode === 0) {
// A green exit must be corroborated by a parseable green transcript:
// at least one pass marker, no fail markers, and the summary section.
if (passLines.length > 0 && failLines.length === 0 && output.includes(summaryMarker)) {
return { status: 'passed' };
}
return {
status: 'error',
reason: `malformed probe output: exit 0 without a parseable pass transcript (${passLines.length} pass markers, ${failLines.length} fail markers, summary ${output.includes(summaryMarker) ? 'present' : 'absent'})`,
};
}
if (outcome.exitCode === 1) {
if (failLines.length === 0) {
return {
status: 'error',
reason: 'malformed probe output: exit 1 without parseable FAIL markers',
};
}
return {
status: 'failed',
reason: `enforcement probe reported ${failLines.length} failing sub-probe(s): ${failLines.join(' | ')}`,
};
}
return {
status: 'error',
reason: `probe exited with unexpected code ${String(outcome.exitCode)} — outcome not interpretable`,
};
}
// ─── Per-subject check-set policy ───────────────────────────────────────────
//
// Gap 7 of the probe inventory: check sets must be selected per subject, not
// one global list. Downstream scaffold kinds get the presence check plus the
// behavioral probe (QC-20 blocks until a probePath input is provided — an
// unverified subject can never evaluate green). The monorepo subject is this
// repository itself: its rails are the husky hooks + shared lint/format
// configs, covered by QC-19; the downstream planted-commit probe does not
// apply to it (this repo's own commit gates are QC-13/QC-14, outside this
// evaluator's owned checks).
const checkSetPolicySpec: CheckSetPolicySpec = {
version: '1.0.0',
byKind: {
node: ['qc-19-rails-files-present', 'qc-20-enforcement-verify'],
python: ['qc-19-rails-files-present', 'qc-20-enforcement-verify'],
rust: ['qc-19-rails-files-present', 'qc-20-enforcement-verify'],
unknown: ['qc-19-rails-files-present', 'qc-20-enforcement-verify'],
monorepo: ['qc-19-rails-files-present'],
},
};
export const CHECK_SET_POLICY: CheckSetPolicy = {
...checkSetPolicySpec,
policyDigest: digestOfPolicy(checkSetPolicySpec),
};
export const QC_19_RAILS_FILES_PRESENT = defineCheck(qc19Spec, evaluateQc19);
export const QC_20_ENFORCEMENT_VERIFY = defineCheck(qc20Spec, evaluateQc20);
/** Built-in check definitions, keyed by id. */
export function builtInDefinitions(): CheckDefinition[] {
return [QC_19_RAILS_FILES_PRESENT, QC_20_ENFORCEMENT_VERIFY];
}
export function checkSetForKind(
kind: SubjectKind,
policy: CheckSetPolicy = CHECK_SET_POLICY,
): readonly string[] {
const selected = policy.byKind[kind];
if (selected === undefined) {
// Fail-closed selection: an unknown kind yields an EMPTY set only to the
// caller; the runner treats an empty result list as `blocked`, never green.
return [];
}
return selected;
}
@@ -1,45 +0,0 @@
import { createHash } from 'node:crypto';
import type { CheckDefinitionSpec, CheckSetPolicySpec } from './types.js';
// Deterministic JSON: object keys sorted at every level so two specs with the
// same content always produce the same bytes (and thus the same digest).
export function canonicalJson(value: unknown): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`;
}
const record = value as Record<string, unknown>;
const keys = Object.keys(record).sort();
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
}
/** sha256 over the canonical JSON of `value`. */
export function digestContent(value: unknown): string {
return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex');
}
/**
* Content digest of a check definition: covers the declarative spec (id,
* version, canonical check, description, applicability, params) everything a
* reviewer reasons about while excluding the executable function object.
* Changing any covered field changes the digest, so a recorded digest always
* identifies exactly which definition content produced a verdict.
*/
export function digestOfSpec(spec: CheckDefinitionSpec): string {
return digestContent({
id: spec.id,
version: spec.version,
canonicalCheck: spec.canonicalCheck,
description: spec.description,
appliesTo: spec.appliesTo,
params: spec.params,
});
}
/** Content digest of the per-subject check-set policy. */
export function digestOfPolicy(spec: CheckSetPolicySpec): string {
return digestContent(spec);
}
@@ -1,525 +0,0 @@
import { mkdtemp, mkdir, writeFile, chmod } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { createSpawnProcessAdapter } from './adapter.js';
import {
builtInDefinitions,
CHECK_SET_POLICY,
checkSetForKind,
defineCheck,
QC_19_RAILS_FILES_PRESENT,
QC_20_ENFORCEMENT_VERIFY,
} from './definitions.js';
import { digestOfSpec } from './digest.js';
import { aggregateState, evaluateSubject } from './runner.js';
import type {
AdapterOutcome,
CheckDefinitionSpec,
CheckResult,
EvaluationReport,
ProcessAdapter,
} from './types.js';
// ─── helpers ────────────────────────────────────────────────────────────────
function firstResult(report: EvaluationReport): CheckResult {
const result = report.results[0];
if (result === undefined) {
throw new Error('expected the report to contain at least one result');
}
return result;
}
async function makeTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), 'qr-evaluator-'));
}
async function writeProbeScript(dir: string, name: string, body: string): Promise<string> {
const scriptPath = join(dir, name);
await writeFile(scriptPath, `${body}\n`, 'utf8');
await chmod(scriptPath, 0o755);
return scriptPath;
}
/** Adapter stub that always returns the given outcome (no real process). */
function stubAdapter(outcome: AdapterOutcome): ProcessAdapter {
return {
run: async () => outcome,
};
}
// VERBATIM copy of the pre-absorption presence loop (former cli.ts
// expectedFilesForKind + fileExists loop). This is the PARITY ORACLE: the
// evaluator's typed QC-19 verdict must agree with what the absorbed check
// concluded on the same fixture.
const LEGACY_EXPECTED: Record<'node' | 'python' | 'rust' | 'unknown', string[]> = {
node: ['.eslintrc', 'biome.json', '.githooks/pre-commit', 'PR-CHECKLIST.md'],
python: ['pyproject.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'],
rust: ['rustfmt.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'],
unknown: ['.githooks/pre-commit', 'PR-CHECKLIST.md'],
};
async function legacyPresenceLoop(projectPath: string, kind: keyof typeof LEGACY_EXPECTED) {
const missing: string[] = [];
for (const relativePath of LEGACY_EXPECTED[kind]) {
const fs = await import('node:fs/promises');
try {
await fs.access(join(projectPath, relativePath));
} catch {
missing.push(relativePath);
}
}
return missing;
}
async function scaffoldFixture(kind: keyof typeof LEGACY_EXPECTED, skip: string[] = []) {
const dir = await makeTempDir();
if (kind === 'node') {
await writeFile(join(dir, 'package.json'), '{}\n', 'utf8');
}
if (kind === 'python') {
await writeFile(join(dir, 'pyproject.toml'), '[project]\n', 'utf8');
}
if (kind === 'rust') {
await writeFile(join(dir, 'Cargo.toml'), '[package]\n', 'utf8');
}
for (const relativePath of LEGACY_EXPECTED[kind]) {
if (skip.includes(relativePath)) continue;
await mkdir(join(dir, relativePath, '..'), { recursive: true });
await writeFile(join(dir, relativePath), 'fixture\n', 'utf8');
}
return dir;
}
// ─── QC-19 parity: typed verdict == absorbed presence loop ──────────────────
describe('QC-19 parity with the absorbed presence loop', () => {
const kinds: Array<keyof typeof LEGACY_EXPECTED> = ['node', 'python', 'rust', 'unknown'];
it.each(kinds)('positive fixture (%s): loop said ok ⇒ evaluator passed', async (kind) => {
const dir = await scaffoldFixture(kind);
const oracleMissing = await legacyPresenceLoop(dir, kind);
expect(oracleMissing).toEqual([]);
const report = await evaluateSubject({
subjectPath: dir,
checkIds: [QC_19_RAILS_FILES_PRESENT.id],
});
const result = report.results.find((r) => r.checkId === QC_19_RAILS_FILES_PRESENT.id);
expect(result?.status).toBe('passed');
expect(result?.reason).toBeUndefined();
expect(report.state).toBe('passed');
});
it.each(kinds)(
'negative fixture (%s): loop listed missing ⇒ evaluator failed with them',
async (kind) => {
const all = LEGACY_EXPECTED[kind];
const skip = all.slice(0, Math.max(1, all.length - 1)); // leave exactly 1 present
const dir = await scaffoldFixture(kind, skip);
const oracleMissing = await legacyPresenceLoop(dir, kind);
expect(oracleMissing.length).toBeGreaterThan(0);
const report = await evaluateSubject({
subjectPath: dir,
checkIds: [QC_19_RAILS_FILES_PRESENT.id],
});
const result = report.results.find((r) => r.checkId === QC_19_RAILS_FILES_PRESENT.id);
expect(result?.status).toBe('failed');
expect(report.state).toBe('failed');
for (const missingFile of oracleMissing) {
expect(result?.reason).toContain(missingFile);
}
// No false attribution: a present file must not be named in the reason.
const presentFile = all.find((file) => !skip.includes(file));
if (presentFile !== undefined) {
expect(result?.reason).not.toContain(` ${presentFile},`);
}
},
);
});
// ─── per-subject check sets (inventory gap 7) ────────────────────────────────
describe('per-subject check sets', () => {
it('monorepo subject selects only QC-19 with the monorepo file set', async () => {
const dir = await makeTempDir();
await writeFile(join(dir, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n', 'utf8');
for (const file of [
'.husky/pre-commit',
'.husky/pre-push',
'eslint.config.mjs',
'.prettierrc',
'.lintstagedrc',
]) {
await mkdir(join(dir, file, '..'), { recursive: true });
await writeFile(join(dir, file), 'fixture\n', 'utf8');
}
const report = await evaluateSubject({ subjectPath: dir });
expect(report.subject.kind).toBe('monorepo');
expect(report.results.map((r) => r.checkId)).toEqual(['qc-19-rails-files-present']);
expect(report.state).toBe('passed');
});
it('a monorepo missing one of its rails files fails QC-19 (not the node list)', async () => {
const dir = await makeTempDir();
await writeFile(join(dir, 'pnpm-workspace.yaml'), 'packages:\n', 'utf8');
const report = await evaluateSubject({ subjectPath: dir });
const result = report.results.find((r) => r.checkId === QC_19_RAILS_FILES_PRESENT.id);
expect(result?.status).toBe('failed');
expect(result?.reason).toContain('.husky/pre-commit');
// The node-template list must NOT be applied to a monorepo subject.
expect(result?.reason).not.toContain('biome.json');
});
it('the policy selects the behavioral probe for scaffold kinds but not monorepo', () => {
expect(checkSetForKind('node')).toContain(QC_20_ENFORCEMENT_VERIFY.id);
expect(checkSetForKind('unknown')).toContain(QC_20_ENFORCEMENT_VERIFY.id);
expect(checkSetForKind('monorepo')).not.toContain(QC_20_ENFORCEMENT_VERIFY.id);
expect(CHECK_SET_POLICY.version).toBe('1.0.0');
});
});
// ─── negative controls (the point of the card) ───────────────────────────────
describe('negative controls', () => {
it('unknown check id ⇒ error, never passed', async () => {
const dir = await scaffoldFixture('node');
const report = await evaluateSubject({
subjectPath: dir,
checkIds: ['qc-99-does-not-exist'],
});
expect(report.results).toHaveLength(1);
const result = firstResult(report);
expect(result.status).toBe('error');
expect(result.reason).toContain("unknown check id 'qc-99-does-not-exist'");
expect(result.status === 'passed').toBe(false);
expect(report.state).toBe('error');
});
it('missing subject (directory absent) ⇒ blocked for every check, never passed', async () => {
const report = await evaluateSubject({
subjectPath: join(tmpdir(), `qr-evaluator-absent-${Date.now()}`),
});
expect(report.results.length).toBeGreaterThan(0);
for (const result of report.results) {
expect(result.status).toBe('blocked');
expect(result.reason).toContain('subject directory does not exist');
}
expect(report.state).toBe('blocked');
});
it('QC-20 without probePath input ⇒ blocked, never passed', async () => {
const dir = await scaffoldFixture('node');
const report = await evaluateSubject({
subjectPath: dir,
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
});
const result = firstResult(report);
expect(result.status).toBe('blocked');
expect(result.reason).toContain('missing input: probePath');
});
it('QC-20 with a nonexistent probe script ⇒ blocked, never passed', async () => {
const dir = await scaffoldFixture('node');
const report = await evaluateSubject({
subjectPath: dir,
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
inputs: { 'qc-20-enforcement-verify': { probePath: join(dir, 'no-such-probe.sh') } },
});
expect(firstResult(report).status).toBe('blocked');
expect(firstResult(report).reason).toContain('probe script not found');
});
it('adapter process error (spawn failure) ⇒ error, never passed', async () => {
const dir = await scaffoldFixture('node');
const report = await evaluateSubject({
subjectPath: dir,
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
inputs: { 'qc-20-enforcement-verify': { probePath: join(dir, 'PR-CHECKLIST.md') } },
adapter: stubAdapter({ ok: false, kind: 'spawn-error', message: 'ENOENT bash' }),
});
expect(firstResult(report).status).toBe('error');
expect(firstResult(report).reason).toContain('probe process spawn-error');
expect(firstResult(report).status === 'passed').toBe(false);
});
it('adapter timeout ⇒ error, never passed', async () => {
const dir = await scaffoldFixture('node');
const report = await evaluateSubject({
subjectPath: dir,
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
inputs: { 'qc-20-enforcement-verify': { probePath: join(dir, 'PR-CHECKLIST.md') } },
adapter: stubAdapter({ ok: false, kind: 'timeout', message: 'timed out after 120000ms' }),
});
expect(firstResult(report).status).toBe('error');
expect(firstResult(report).reason).toContain('probe process timeout');
});
it('probe exit 1 with parseable FAIL markers ⇒ failed (interpretably red), never passed', async () => {
const dir = await scaffoldFixture('node');
const probe = await writeProbeScript(
dir,
'probe-fail.sh',
`echo "Test 1: ..."\necho "❌ FAIL: Type errors NOT blocked"\necho "Verification Summary"\nexit 1`,
);
const report = await evaluateSubject({
subjectPath: dir,
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
adapter: createSpawnProcessAdapter(),
});
expect(firstResult(report).status).toBe('failed');
expect(firstResult(report).reason).toContain('FAIL: Type errors NOT blocked');
expect(report.state).toBe('failed');
});
it('probe exit 1 WITHOUT parseable FAIL markers ⇒ malformed ⇒ error, never passed', async () => {
const dir = await scaffoldFixture('node');
const probe = await writeProbeScript(dir, 'probe-mute.sh', `echo "nothing to see"\nexit 1`);
const report = await evaluateSubject({
subjectPath: dir,
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
adapter: createSpawnProcessAdapter(),
});
expect(firstResult(report).status).toBe('error');
expect(firstResult(report).reason).toContain('malformed probe output');
});
it('probe exit 0 without a parseable pass transcript ⇒ malformed ⇒ error, never passed', async () => {
const dir = await scaffoldFixture('node');
const probe = await writeProbeScript(dir, 'probe-lie.sh', `echo "all good"\nexit 0`);
const report = await evaluateSubject({
subjectPath: dir,
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
adapter: createSpawnProcessAdapter(),
});
expect(firstResult(report).status).toBe('error');
expect(firstResult(report).reason).toContain('malformed probe output');
expect(firstResult(report).reason).toContain('exit 0');
});
it('probe exit 0 WITH fail markers ⇒ contradictory transcript ⇒ error, never passed', async () => {
const dir = await scaffoldFixture('node');
const probe = await writeProbeScript(
dir,
'probe-contradict.sh',
`echo "✅ PASS: one"\necho "❌ FAIL: two"\necho "Verification Summary"\nexit 0`,
);
const report = await evaluateSubject({
subjectPath: dir,
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
adapter: createSpawnProcessAdapter(),
});
expect(firstResult(report).status).toBe('error');
});
it('probe unexpected exit code (7) ⇒ error, never passed', async () => {
const dir = await scaffoldFixture('node');
const probe = await writeProbeScript(dir, 'probe-crash.sh', `echo "boom"\nexit 7`);
const report = await evaluateSubject({
subjectPath: dir,
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
adapter: createSpawnProcessAdapter(),
});
expect(firstResult(report).status).toBe('error');
expect(firstResult(report).reason).toContain('unexpected code 7');
});
it('check implementation throwing ⇒ error, never passed', async () => {
const dir = await scaffoldFixture('node');
const spec: CheckDefinitionSpec = {
id: 'test-throws',
version: '1.0.0',
canonicalCheck: 'QC-TEST',
description: 'sabotage-shaped definition that always throws',
appliesTo: ['node'],
params: {},
};
const throwing = defineCheck(spec, async () => {
throw new Error('kaboom');
});
const report = await evaluateSubject({
subjectPath: dir,
checkIds: ['test-throws'],
definitions: [throwing],
});
expect(firstResult(report).status).toBe('error');
expect(firstResult(report).reason).toContain('kaboom');
});
it('non-passed verdict without a reason ⇒ upgraded to error, never an unqualified skip', async () => {
const dir = await scaffoldFixture('node');
const spec: CheckDefinitionSpec = {
id: 'test-silent-fail',
version: '1.0.0',
canonicalCheck: 'QC-TEST',
description: 'returns failed without a reason',
appliesTo: ['node'],
params: {},
};
const silent = defineCheck(spec, async () => ({ status: 'failed' }));
const report = await evaluateSubject({
subjectPath: dir,
checkIds: ['test-silent-fail'],
definitions: [silent],
});
expect(firstResult(report).status).toBe('error');
expect(firstResult(report).reason).toContain('without a reason');
});
it('empty result list aggregates to blocked, never passed', () => {
expect(aggregateState([])).toBe('blocked');
});
});
// ─── QC-20 parity: typed verdict == shell probe's own conclusion ────────────
describe('QC-20 parity with the shell probe contract', () => {
it('green transcript (exit 0) ⇒ evaluator passed', async () => {
const dir = await scaffoldFixture('node');
const probe = await writeProbeScript(
dir,
'probe-pass.sh',
[
'echo "✅ PASS: Type errors blocked"',
'echo "✅ PASS: any types blocked"',
'echo "✅ PASS: Lint errors blocked"',
'echo "Verification Summary"',
'echo "✅ Passed: 3"',
'exit 0',
].join('\n'),
);
const report = await evaluateSubject({
subjectPath: dir,
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
adapter: createSpawnProcessAdapter(),
});
expect(firstResult(report).status).toBe('passed');
expect(report.state).toBe('passed');
});
it('the REAL framework verify.sh on a non-git subject concludes failed (exit 1) ⇒ evaluator failed', async () => {
// Real-probe parity: verify.sh without a git repo cannot block planted
// commits, exits 1 with FAIL markers — the evaluator must record exactly
// `failed` with those markers, matching the probe's own conclusion.
const realProbe = fileURLToPath(
new URL('../../../mosaic/framework/tools/quality/scripts/verify.sh', import.meta.url),
);
const dir = await makeTempDir(); // not a git repository, no hooks
const report = await evaluateSubject({
subjectPath: dir,
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
inputs: { 'qc-20-enforcement-verify': { probePath: realProbe } },
adapter: createSpawnProcessAdapter(),
});
const result = firstResult(report);
expect(result.status).toBe('failed');
expect(result.reason).toMatch(/FAIL:/);
expect(report.state).toBe('failed');
});
});
// ─── version / digest discipline ─────────────────────────────────────────────
describe('versioned, digested check definitions', () => {
it('every verdict records the definition version that produced it', async () => {
const dir = await scaffoldFixture('node');
const report = await evaluateSubject({ subjectPath: dir });
for (const result of report.results) {
expect(result.checkVersion).toBe('1.0.0');
}
expect(report.checkSetVersion).toBe(CHECK_SET_POLICY.version);
});
it('the report records each definitions content digest', async () => {
const dir = await scaffoldFixture('node');
const report = await evaluateSubject({ subjectPath: dir });
expect(report.definitionDigests[QC_19_RAILS_FILES_PRESENT.id]).toBe(
QC_19_RAILS_FILES_PRESENT.definitionDigest,
);
expect(report.definitionDigests[QC_20_ENFORCEMENT_VERIFY.id]).toBe(
QC_20_ENFORCEMENT_VERIFY.definitionDigest,
);
});
it('digests are stable for identical content', () => {
const spec = QC_19_RAILS_FILES_PRESENT;
expect(digestOfSpec(spec)).toBe(digestOfSpec(spec));
});
it('changing a definitions content changes its digest', () => {
const base = { ...QC_19_RAILS_FILES_PRESENT } as CheckDefinitionSpec;
const baseDigest = digestOfSpec(base);
const changedParams: CheckDefinitionSpec = {
...base,
params: {
expectedFilesByKind: {
...(base.params['expectedFilesByKind'] as Record<string, string[]>),
node: ['.eslintrc', 'biome.json', '.githooks/pre-commit', 'PR-CHECKLIST.md', 'NEW.md'],
},
},
};
expect(digestOfSpec(changedParams)).not.toBe(baseDigest);
const changedVersion: CheckDefinitionSpec = { ...base, version: '1.1.0' };
expect(digestOfSpec(changedVersion)).not.toBe(baseDigest);
});
it('a definition with changed content produces a different recorded digest and version', async () => {
const dir = await scaffoldFixture('node');
const modified = defineCheck(
{ ...QC_19_RAILS_FILES_PRESENT, version: '2.0.0' } as unknown as CheckDefinitionSpec,
async () => ({ status: 'passed' }),
);
const report = await evaluateSubject({
subjectPath: dir,
checkIds: [QC_19_RAILS_FILES_PRESENT.id],
definitions: [
modified,
...builtInDefinitions().filter((d) => d.id !== QC_19_RAILS_FILES_PRESENT.id),
],
});
const result = firstResult(report);
expect(result.checkVersion).toBe('2.0.0');
expect(report.definitionDigests[QC_19_RAILS_FILES_PRESENT.id]).toBe(modified.definitionDigest);
expect(modified.definitionDigest).not.toBe(QC_19_RAILS_FILES_PRESENT.definitionDigest);
});
});
// ─── aggregate state ordering (MACP-style discipline) ───────────────────────
describe('aggregate state precedence', () => {
const result = (status: 'passed' | 'failed' | 'blocked' | 'error') => ({
status,
checkId: 'x',
checkVersion: '1.0.0',
subject: '/tmp/x',
});
it('all passed (with not-applicable) ⇒ passed', () => {
expect(
aggregateState([
result('passed'),
{ ...result('passed'), status: 'not-applicable' as const },
]),
).toBe('passed');
});
it('error outranks blocked and failed; blocked outranks failed', () => {
expect(aggregateState([result('blocked'), result('error')])).toBe('error');
expect(aggregateState([result('failed'), result('blocked')])).toBe('blocked');
expect(aggregateState([result('passed'), result('failed')])).toBe('failed');
});
});
@@ -1,183 +0,0 @@
import { constants } from 'node:fs';
import { access, stat } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { createSpawnProcessAdapter } from './adapter.js';
import { builtInDefinitions, CHECK_SET_POLICY, checkSetForKind } from './definitions.js';
import type {
AggregateState,
CheckResult,
CheckStatus,
EvaluateOptions,
EvaluationReport,
ProcessAdapter,
Subject,
SubjectKind,
} from './types.js';
import { detectProjectKind } from '../detect.js';
async function pathExists(targetPath: string): Promise<boolean> {
try {
await access(targetPath, constants.F_OK);
return true;
} catch {
return false;
}
}
async function isDirectory(targetPath: string): Promise<boolean> {
try {
return (await stat(targetPath)).isDirectory();
} catch {
return false;
}
}
/**
* Subject-kind detection for the evaluator. Extends the scaffold detection
* (detect.ts) with the `monorepo` kind: a pnpm workspace is this repository's
* own subject shape and carries a different rails file set (probe-inventory
* gap 7 check sets are per subject, not one global file list).
*/
export async function detectSubjectKind(subjectPath: string): Promise<SubjectKind> {
if (await pathExists(join(subjectPath, 'pnpm-workspace.yaml'))) {
return 'monorepo';
}
const kind = await detectProjectKind(subjectPath);
return kind;
}
/**
* Aggregate state, MACP-style discipline: `passed` only when at least one
* check produced a verdict AND every verdict is `passed` or an explicitly
* qualified `not-applicable`. Precedence is fail-closed: error > blocked >
* failed > passed; an empty result list aggregates to `blocked`.
*/
export function aggregateState(results: readonly CheckResult[]): AggregateState {
if (results.length === 0) {
return 'blocked';
}
const has = (status: CheckStatus): boolean => results.some((result) => result.status === status);
if (has('error')) {
return 'error';
}
if (has('blocked')) {
return 'blocked';
}
if (has('failed')) {
return 'failed';
}
return 'passed';
}
function reasonFrom(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* Evaluate one subject against a set of checks, producing typed verdicts.
*
* Fail-closed invariants (RI-N4):
* - unknown check id `error` (never passed)
* - subject directory absent every verdict `blocked`
* - check implementation threw `error`
* - non-passed without a reason `error` (no unqualified skips)
* - check not applicable `not-applicable` WITH a reason
*/
export async function evaluateSubject(options: EvaluateOptions): Promise<EvaluationReport> {
const subjectPath = resolve(options.subjectPath);
const subject: Subject = {
path: subjectPath,
kind: await detectSubjectKind(subjectPath),
};
const definitions = options.definitions ?? builtInDefinitions();
const byId = new Map(definitions.map((definition) => [definition.id, definition]));
const requested = options.checkIds ?? checkSetForKind(subject.kind);
const adapter: ProcessAdapter = options.adapter ?? createSpawnProcessAdapter();
const results: CheckResult[] = [];
const definitionDigests: Record<string, string> = {};
for (const checkId of requested) {
const definition = byId.get(checkId);
if (definition === undefined) {
const known = definitions.map((entry) => entry.id).join(', ');
results.push({
status: 'error',
checkId,
checkVersion: 'unknown',
subject: subjectPath,
reason: `unknown check id '${checkId}' — no registered definition (known: ${known})`,
});
continue;
}
definitionDigests[checkId] = definition.definitionDigest;
if (!(await isDirectory(subjectPath))) {
results.push({
status: 'blocked',
checkId,
checkVersion: definition.version,
subject: subjectPath,
reason: `subject directory does not exist: ${subjectPath}`,
});
continue;
}
if (!definition.appliesTo.includes(subject.kind)) {
results.push({
status: 'not-applicable',
checkId,
checkVersion: definition.version,
subject: subjectPath,
reason: `check '${checkId}' does not apply to subject kind '${subject.kind}'`,
});
continue;
}
try {
const inputs = options.inputs?.[checkId] ?? {};
const outcome = await definition.evaluate({
subject,
params: definition.params,
inputs,
adapter,
});
if (outcome.status !== 'passed' && (outcome.reason === undefined || outcome.reason === '')) {
results.push({
status: 'error',
checkId,
checkVersion: definition.version,
subject: subjectPath,
reason: `check returned status '${outcome.status}' without a reason — treated as error`,
});
continue;
}
results.push({
status: outcome.status,
checkId,
checkVersion: definition.version,
subject: subjectPath,
reason: outcome.reason,
});
} catch (error) {
results.push({
status: 'error',
checkId,
checkVersion: definition.version,
subject: subjectPath,
reason: `check implementation threw: ${reasonFrom(error)}`,
});
}
}
return {
subject,
results,
definitionDigests,
checkSetVersion: CHECK_SET_POLICY.version,
state: aggregateState(results),
};
}
@@ -1,148 +0,0 @@
// Evaluator core types — RI-N4 (card RI-3-002, SDLC-D-037 second half).
//
// The quality-rails evaluator is the SOLE authoritative producer of check
// verdicts for the checks it owns. Every verdict is typed and fail-closed:
// missing implementations, missing inputs, unknown check ids, process errors,
// timeouts, and malformed adapter output can never become `passed` or an
// unqualified skip — they surface as `blocked` or `error` with a reason.
// (Vocabulary mirrors MACP's GateStatus discipline from packages/macp.)
/**
* Typed verdict for a single check execution.
*
* - `passed` the check really ran and its condition held.
* - `failed` the check really ran and its condition did NOT hold.
* - `blocked` the check could not run at all (missing subject, missing
* input). Never a green outcome.
* - `error` the check attempted to run but its outcome cannot be trusted
* (unknown check id, implementation threw, process error, timeout, malformed
* adapter output). Never a green outcome.
* - `not-applicable` the check definition explicitly declares it does not
* apply to this subject (a qualified skip, always with a reason).
*/
export type CheckStatus = 'passed' | 'failed' | 'blocked' | 'error' | 'not-applicable';
/** Aggregate outcome, MACP-style: `passed` only when every result is green. */
export type AggregateState = 'passed' | 'failed' | 'blocked' | 'error';
/** Kinds of subjects the evaluator can assess. */
export type SubjectKind = 'node' | 'python' | 'rust' | 'monorepo' | 'unknown';
/**
* A single check verdict. This is the canonical result shape: `status`,
* `checkId`, `checkVersion`, `subject`, `reason`. `reason` is REQUIRED
* (enforced by the runner) for every status other than `passed`.
*/
export interface CheckResult {
status: CheckStatus;
checkId: string;
checkVersion: string;
subject: string;
reason?: string;
}
/** The project being evaluated. */
export interface Subject {
/** Absolute path. */
path: string;
kind: SubjectKind;
}
/**
* The data half of a check definition. Definitions live as DATA with a version
* and a content digest (see `digestOfSpec`); the executable half is attached
* separately so the digest covers only reviewable, declarative content.
*/
export interface CheckDefinitionSpec {
/** Stable id, e.g. `qc-19-rails-files-present`. */
id: string;
/** Semver of this definition's data+semantics. */
version: string;
/** Canonical check id from docs/release-integrity/probe-inventory.md (QC-n). */
canonicalCheck: string;
description: string;
/** Subject kinds this check can assess (others yield `not-applicable`). */
appliesTo: readonly SubjectKind[];
/** Declarative parameters (file lists, markers, timeouts) — digest-covered. */
params: Record<string, unknown>;
}
/** A fully assembled check definition: spec + digest + implementation. */
export interface CheckDefinition extends CheckDefinitionSpec {
/** sha256 content digest of the spec (canonical JSON projection). */
definitionDigest: string;
evaluate(ctx: CheckContext): Promise<CheckOutcome>;
}
/** What a check implementation returns; the runner stamps id/version/subject. */
export interface CheckOutcome {
status: CheckStatus;
reason?: string;
}
/** Caller-provided inputs for one check invocation (e.g. the QC-20 probe path). */
export type CheckInputs = Record<string, unknown>;
/** Everything a check implementation may use. */
export interface CheckContext {
subject: Subject;
params: Record<string, unknown>;
inputs: CheckInputs;
adapter: ProcessAdapter;
}
/** Outcome of running a shell probe through the thin process adapter. */
export type AdapterOutcome =
| { ok: true; exitCode: number | null; stdout: string; stderr: string }
| { ok: false; kind: 'spawn-error' | 'timeout'; message: string };
/** Request for the process adapter. */
export interface AdapterRequest {
file: string;
args: string[];
cwd: string;
timeoutMs: number;
}
/**
* Thin process adapter: runs a command, owns NO verdict logic. Verdict parsing
* always lives in the check implementation (TS), never in the shell probe.
*/
export interface ProcessAdapter {
run(request: AdapterRequest): Promise<AdapterOutcome>;
}
/** Per-subject-kind check-set selection policy (versioned and digested). */
export interface CheckSetPolicySpec {
version: string;
byKind: Record<SubjectKind, readonly string[]>;
}
export interface CheckSetPolicy extends CheckSetPolicySpec {
/** sha256 content digest of the policy spec. */
policyDigest: string;
}
/** Full typed evaluation report for one subject. */
export interface EvaluationReport {
subject: Subject;
results: CheckResult[];
/** checkId → content digest of the definition that produced the verdicts. */
definitionDigests: Record<string, string>;
/** Version of the check-set policy used for subject selection. */
checkSetVersion: string;
state: AggregateState;
}
/** Options for `evaluateSubject`. */
export interface EvaluateOptions {
subjectPath: string;
/** Restrict to these check ids; defaults to the subject kind's check set. */
checkIds?: string[];
/** Per-check inputs, keyed by check id (e.g. `{ 'qc-20-enforcement-verify': { probePath } }`). */
inputs?: Record<string, CheckInputs>;
/** Replace the built-in definitions (tests / future batches). */
definitions?: CheckDefinition[];
/** Inject a process adapter (tests / instrumentation). */
adapter?: ProcessAdapter;
}
-32
View File
@@ -3,35 +3,3 @@ export * from './detect.js';
export * from './scaffolder.js';
export * from './templates.js';
export * from './types.js';
// RI-N4 evaluator (card RI-3-002): the public, programmatic entry points.
export {
builtInDefinitions,
CHECK_SET_POLICY,
checkSetForKind,
defineCheck,
QC_19_RAILS_FILES_PRESENT,
QC_20_ENFORCEMENT_VERIFY,
} from './evaluator/definitions.js';
export { canonicalJson, digestContent, digestOfPolicy, digestOfSpec } from './evaluator/digest.js';
export { createSpawnProcessAdapter } from './evaluator/adapter.js';
export { aggregateState, detectSubjectKind, evaluateSubject } from './evaluator/runner.js';
export type {
AdapterOutcome,
AdapterRequest,
AggregateState,
CheckContext,
CheckDefinition,
CheckDefinitionSpec,
CheckInputs,
CheckOutcome,
CheckResult,
CheckSetPolicy,
CheckSetPolicySpec,
CheckStatus,
EvaluateOptions,
EvaluationReport,
ProcessAdapter,
Subject,
SubjectKind,
} from './evaluator/types.js';
@@ -1,45 +0,0 @@
# RI-1-002 — Publish-gate negative controls (SDLC-D-034 second half)
- Task: RI-1-002 (docs/release-integrity workstream, PRD item RI-N1), issue ref #1275
- Branch: `test/ri-050-publish-gate-negative` (base `origin/next` @ d8e0aec9 = PR #1277, RI-1-001)
- Budget: worker estimate ~45K tokens; keep scoped to the two test files + scratchpad.
## Objective
Checked-in negative-control tests that PROVE the publish gate fails when it must:
1. Broken mandatory check blocks every publish step (structural DAG proof from `.woodpecker/publish.yml`).
2. Bypass shapes fail the checker: missing edge, hidden effect (non-`publish` name), detached verify, always-pass verify (`failure: ignore` / `success` override), conditional verify (`when`).
3. Exact-commit identity: no HEAD-moving step between verify and publish effects; legitimate re-checkout requires verify to re-run after it.
4. `verify-release.mjs` composition control: a SUBSET stage list fails the composition check.
## Plan
- NEW `scripts/publish-gate-structure.test.mjs` — self-contained structural checker (`assertPublishGateBlocksOnVerify`) + positive control on the real pipeline + one negative-control test per bypass shape (S1S6, documented in file header) + positive control for the legitimate re-checkout shape.
- EXTEND `scripts/verify-release.test.mjs` — refactor the stage-mirror test body into `assertStagesMirrorCi(stages, ci)`; add negative control dropping each stage one at a time (subset must throw).
## Conventions confirmed
- Root `test:checkout` = `node --test scripts/*.test.mjs` → new file auto-joins `pnpm test`.
- Test-enumeration guard population is `*test*.sh` under `packages/mosaic/framework/tools/` only → unaffected.
- Root eslint covers only `**/*.{ts,tsx}` → .mjs files need Prettier style only (printWidth 100, singleQuote, semi, trailingComma all).
- Do NOT touch docs/TASKS.md, docs/release-integrity/TASKS.md, docs/scratchpads/.
## Progress log
- [x] Base verified: publish.yml `verify` step + verify-release.mjs present; HEAD contains origin/next.
- [x] Wrote scripts/publish-gate-structure.test.mjs
- [x] Extended scripts/verify-release.test.mjs (mirror fn + subset negative control)
- [x] Gates: node --test scripts (31 tests pass), prettier clean on touched files, pnpm typecheck PASS, pnpm lint PASS, pnpm format:check PASS
- [x] Committed ff585b88 + pushed, PR #1305 → next (no conflicts). Stopped before merge per task instruction.
## Evidence
- `node --test scripts/verify-release.test.mjs scripts/publish-gate-structure.test.mjs` → 31 tests, 0 fail.
- Mutation sanity: temporarily removing the `verify` edge from build-gateway in publish.yml → structure test goes red (verified manually during dev, then reverted).
- Gates run from repo root on this worktree; results in Progress log.
## Risks / notes
- Effect detection (`isPublishCommand`) is deliberately over-broad (any npm/pnpm/yarn command mentioning `publish`, any kaniko/docker-push/`--destination`) — fail-closed: a false positive forces justification, a false negative is the actual hazard.
- `git fetch` flagged as HEAD-moving even though fetch alone doesn't move HEAD — fail-closed on the classic `fetch && reset` pair.
-37
View File
@@ -1,37 +0,0 @@
# Scratchpad — RI-4-001 One transitional PRD authority (RI-N3, #1275)
- Objective: single PrdService authority in `@mosaicstack/prdy`; `mosaic prdy` and
`mission --plan` become thin adapters; mission↔PRD linkage persisted on disk;
Markdown export is a labeled generated view (never read back); import is
validated/conflict-aware with typed refusals.
- Budget: ~35K tokens (card cap). Baselines: prdy build/lint rc=0, 0 tests;
mosaic build rc=0 (after root turbo build), lint rc=0, 1548 tests pass;
root build rc=0.
- Plan: (1) extend store schema (version, missions linkage) (2) PrdService +
typed errors (3) wizard/cli route through service (4) mosaic adapters
(5) contract specs both packages (6) gates (7) sabotage control (8) report
to /var/tmp/ri-050/ri-4-001-report.md.
- Decisions:
- Linkage lives ON the PRD document (`missions` array) — one authority file,
survives restart, no sidecar sync problems.
- `version` = content revision of sections/status (bumped by update/import
accept). Linkage writes bump `updatedAt` only, so ids/versions stay stable
for the card's "stable ids/versions" contract.
- Mission version marker = `mission.updatedAt` (gateway MissionInfo has no
numeric version field).
- Import reads YAML documents only — never the exported Markdown (keeps the
"no code path reads exported Markdown" invariant).
- Import of an existing id with identical core content → `identical` no-op;
divergent → typed `PrdImportConflictError` carrying proposed successor
(existing.version + 1, status draft, linkages preserved). Original bytes
untouched until explicit `acceptSuccessor`.
- `requirementIds` default `[]` at the mission command (no requirement
selection UI yet) — service accepts ids when a caller has them.
- Progress log:
- [16:35] baselines captured (prdy 0 tests; mosaic 1548 after root build; root build rc=0)
- [16:38] store schema v2 + PrdService + wizard/cli rerouted; prdy build/lint green
- [16:40] mosaic adapters done; prdy spec 20/20 (found+fixed: import project-path leak, empty-store typed error, YAML timestamp coercion)
- [16:44] mosaic specs 9/9 (fixed commander from:'user' argv, vi.mock hoisting, restoreAllMocks wiping factory mocks)
- [16:45] all gates green; 4 commits (e291bfb, 2c5d208, a23826c, 540d6f1)
- [16:46] sabotage: linkage write removed → prdy 3 fail / mosaic 2 fail, 1548/1548 pre-existing pass; restored byte-identically; re-green 20/20 + 1557/1557
- [16:47] report written to /var/tmp/ri-050/ri-4-001-report.md — card complete
-310
View File
@@ -1,310 +0,0 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import test from 'node:test';
// RI-1-002 / RI-N1 publish-gate NEGATIVE CONTROLS (SDLC-D-034).
//
// scripts/verify-release.test.mjs pins the POSITIVE structure of the publish
// gate: every publish effect declares a direct `depends_on: verify` edge and
// the verify step asserts commit identity + runs the canonical command. This
// suite is the negative-control set: each test feeds a structural gate
// checker a pipeline in which the gate is bypassed by ONE specific shape and
// asserts the checker goes RED. The controls prove from the pipeline FILE —
// never by executing Woodpecker — that a verify step that FAILS (nonzero
// exit) blocks every publish effect.
//
// Woodpecker semantics these controls rely on:
// - A step that exits nonzero FAILS, and every step that transitively
// depends on a failed step is SKIPPED — never run. That skip is the only
// thing standing between a failed mandatory check and a publish effect.
// - `detach: true` removes the step from the wait graph: the pipeline does
// not wait for detached steps, so their failure can never block anything.
// - `failure: ignore` reports a failed step as success to the DAG.
// - `success: [codes...]` overrides which exit codes count as success;
// admitting any nonzero code launders a failed verification into green.
// - `when` on the verify step would skip verification entirely on some
// event/path classes while publish effects still run.
//
// Bypass shapes covered (one negative-control test each):
// S1 Missing edge — a publish effect whose dependency closure does not
// contain `verify` (a refactor drops the depends_on entry).
// S2 Hidden effect — a step whose NAME does not start with `publish` but
// whose COMMANDS publish npm packages or push images. Effects are
// classified by commands, so renaming a step cannot un-gate it.
// S3 Detached verify — `verify: { detach: true }`: publish steps no longer
// wait for verify, so the depends_on edge is decorative.
// S4 Always-pass verify — `failure: ignore`, or a `success` override
// admitting nonzero exit codes: verify fails, the DAG sees success.
// S5 Conditional verify — a `when`/path filter on verify itself.
// S6 Exact-commit drift — a HEAD-moving step (git checkout/switch/reset/
// clean/pull/clone/fetch) ordered between `verify` and a publish
// effect: the verified commit would not be the published commit. A
// LEGITIMATE re-checkout is allowed only when `verify` itself runs
// after it — positive control included.
// S7 Gate removal — the verify step deleted or renamed away entirely.
// Reuse the monorepo's existing YAML parser (@mosaicstack/mosaic's direct
// dependency) instead of adding a root dependency or vendoring a parser.
const mosaicRequire = createRequire(
path.resolve(process.cwd(), 'packages', 'mosaic', 'package.json'),
);
const { parse: parseYaml } = mosaicRequire('yaml');
const publishYmlPath = path.join(process.cwd(), '.woodpecker', 'publish.yml');
async function readPublishPipeline() {
return parseYaml(await readFile(publishYmlPath, 'utf8'));
}
// A command has a publish EFFECT when it publishes npm packages (`publish`
// anywhere after a package-manager token — `pnpm --filter "@x/*" publish`
// puts flags and quoted filters between the binary and the subcommand) or
// pushes an image (kaniko, docker push, or a registry --destination).
// Deliberately over-broad: a false positive forces justification, a false
// negative is the actual hazard.
function isPublishCommand(command) {
return (
/(^|\s)\/kaniko\/executor\b/.test(command) ||
/(^|\s)docker\s+push\b/.test(command) ||
/(^|\s)--destination(\s|=)/.test(command) ||
(/\bpublish\b/.test(command) && /(^|\s)(npm|pnpm|yarn)(\s|$)/.test(command))
);
}
function hasPublishEffect(step) {
return (step.commands ?? []).some(isPublishCommand);
}
// A step is a publish effect when its name says so OR (S2) when any of its
// commands does — classification must not depend on the name alone.
function publishEffectSteps(pipeline) {
return Object.entries(pipeline.steps ?? {})
.filter(([name, step]) => name.startsWith('publish') || hasPublishEffect(step))
.map(([name]) => name);
}
// Transitive closure of a step's depends_on graph.
function dependencyClosure(pipeline, stepName, seen = new Set()) {
const dependencies = pipeline.steps?.[stepName]?.depends_on ?? [];
for (const dependency of dependencies) {
if (seen.has(dependency)) continue;
seen.add(dependency);
dependencyClosure(pipeline, dependency, seen);
}
return seen;
}
// Deliberately over-broad: `git fetch` alone does not move HEAD, but the
// classic re-checkout pair is `git fetch && git reset --hard <remote>`; a
// fetch step sitting between verify and a publish effect deserves scrutiny,
// so the gate fails closed on it.
function movesHead(step) {
return (step.commands ?? []).some((command) =>
/(^|\s)git\s+(checkout|switch|reset|clean|pull|clone|fetch)\b/.test(command),
);
}
// The structural gate checker: green only when a failed (nonzero-exit)
// verify provably blocks every publish effect on the same commit.
function assertPublishGateBlocksOnVerify(pipeline) {
assert.ok(pipeline.steps, 'publish pipeline must define steps');
const verify = pipeline.steps.verify;
assert.ok(verify, 'publish pipeline must define a `verify` step (S7)');
// S5: a skipped verification authorizes publishes exactly as much as a
// failed one — verify must be unconditional.
assert.equal(verify.when, undefined, '`verify` must not carry a when/path filter (S5)');
// S3/S4: the depends_on edges are only meaningful if verify's own failure
// is both awaited and terminal for the DAG.
assert.equal(verify.detach, undefined, '`verify` must not be detached (S3)');
assert.equal(
verify.failure,
undefined,
'`verify` must not tolerate its own failure (S4: failure: ignore launders a failed gate into success)',
);
assert.equal(
verify.success,
undefined,
'`verify` must not override success exit codes (S4: nonzero codes would make failed verification pass)',
);
const effects = publishEffectSteps(pipeline);
assert.ok(effects.length > 0, 'publish pipeline must contain publish effect steps to guard');
const verifyClosure = dependencyClosure(pipeline, 'verify');
for (const stepName of effects) {
// S1: only the failure-skip semantics of the DAG stand between a failed
// verify and this effect — the verify edge in its closure is the proof.
const closure = dependencyClosure(pipeline, stepName);
assert.ok(
closure.has('verify'),
`publish effect '${stepName}' must transitively depend on verify (S1) — a failed verify must skip it`,
);
// S6: any step ordered after verify (outside its closure) but inside the
// effect's chain must not be able to move HEAD. If the pipeline
// legitimately re-checks-out, verify must run after the re-checkout.
for (const chainStep of closure) {
if (chainStep === 'verify' || verifyClosure.has(chainStep)) continue;
assert.ok(
!movesHead(pipeline.steps[chainStep]),
`step '${chainStep}' sits between verify and publish effect '${stepName}' and can move HEAD (S6)` +
' — verify must re-run after any re-checkout',
);
}
}
return effects;
}
// A minimal but healthy gate used as the base for every negative-control
// mutation: verify (identity + canonical command) → build → publish-npm,
// with the publish effect blocked by verify both directly and through build.
const HEALTHY_GATE_YAML = `
steps:
verify:
image: node:24-alpine
commands:
- |
if [ -z "$CI_COMMIT_SHA" ] || [ "$CI_COMMIT_SHA" != "$(git rev-parse HEAD)" ]; then
echo "identity mismatch" >&2
exit 1
fi
- pnpm verify:release
build:
image: node:24-alpine
commands:
- pnpm build
depends_on:
- verify
publish-npm:
image: node:24-alpine
commands:
- npm publish
depends_on:
- build
- verify
`;
// Fresh parse per call so every negative control mutates its own object.
function healthyPipeline() {
return parseYaml(HEALTHY_GATE_YAML);
}
test('the real publish pipeline: a failed verify provably blocks every publish effect', async () => {
const pipeline = await readPublishPipeline();
const effects = assertPublishGateBlocksOnVerify(pipeline);
assert.deepEqual(effects.sort(), [
'build-appservice',
'build-gateway',
'build-web',
'publish-next-npm',
'publish-npm',
]);
});
test('fixture sanity: the healthy gate base passes the checker unmutated', () => {
assertPublishGateBlocksOnVerify(healthyPipeline());
});
test('S1 negative control: a publish effect with no verify edge fails the checker', () => {
const pipeline = healthyPipeline();
pipeline.steps['publish-npm'].depends_on = ['build'];
pipeline.steps.build.depends_on = [];
assert.throws(
() => assertPublishGateBlocksOnVerify(pipeline),
/publish-npm.*must transitively depend on verify/s,
);
});
test('S2 negative control: an npm publish hidden behind a non-publish step name fails the checker', () => {
const pipeline = healthyPipeline();
delete pipeline.steps['publish-npm'];
pipeline.steps.build.depends_on = [];
pipeline.steps.deploy = {
image: 'node:24-alpine',
commands: ['npm publish'],
depends_on: ['build'],
};
// Detection must be by COMMAND: the name says "deploy", the commands say
// publish — an un-gated effect under either reading.
assert.throws(
() => assertPublishGateBlocksOnVerify(pipeline),
/deploy.*must transitively depend on verify/s,
);
});
test('S2 negative control: a kaniko image push under a build-* name fails the checker when ungated', () => {
const pipeline = healthyPipeline();
delete pipeline.steps['publish-npm'];
pipeline.steps.build.depends_on = [];
pipeline.steps['push-platform-image'] = {
image: 'gcr.io/kaniko-project/executor:debug',
commands: ['/kaniko/executor --context . --destination reg.example/img:latest'],
depends_on: ['build'],
};
assert.throws(
() => assertPublishGateBlocksOnVerify(pipeline),
/push-platform-image.*must transitively depend on verify/s,
);
});
test('S3 negative control: a detached verify fails the checker', () => {
const pipeline = healthyPipeline();
pipeline.steps.verify.detach = true;
assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /detached \(S3\)/);
});
test('S4 negative control: failure: ignore on verify fails the checker', () => {
const pipeline = healthyPipeline();
pipeline.steps.verify.failure = 'ignore';
assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /tolerate its own failure/);
});
test('S4 negative control: a success override admitting nonzero exit codes fails the checker', () => {
const pipeline = healthyPipeline();
pipeline.steps.verify.success = [0, 1];
assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /success exit codes/);
});
test('S5 negative control: a when filter on verify fails the checker', () => {
const pipeline = healthyPipeline();
pipeline.steps.verify.when = [{ event: 'push' }];
assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /when\/path filter \(S5\)/);
});
test('S6 negative control: a HEAD-moving step between verify and publish fails the checker', () => {
const pipeline = healthyPipeline();
pipeline.steps.resync = {
image: 'node:24-alpine',
commands: ['git fetch origin', 'git reset --hard origin/main'],
depends_on: [],
};
pipeline.steps.build.depends_on = ['verify', 'resync'];
// resync sits AFTER verify in the publish chain (verify does not depend on
// it), so the verified commit could be replaced before publishing.
assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /resync.*can move HEAD/s);
});
test('S6 positive control: a legitimate re-checkout passes when verify re-runs after it', () => {
const pipeline = healthyPipeline();
pipeline.steps.resync = {
image: 'node:24-alpine',
commands: ['git fetch origin', 'git reset --hard origin/main'],
depends_on: [],
};
pipeline.steps.verify.depends_on = ['resync'];
pipeline.steps.build.depends_on = ['verify'];
// resync precedes verify in the chain, so verification covers the
// re-checked-out HEAD — the exact-commit contract holds.
assertPublishGateBlocksOnVerify(pipeline);
});
test('S7 negative control: deleting the verify step entirely fails the checker', () => {
const pipeline = healthyPipeline();
delete pipeline.steps.verify;
pipeline.steps['publish-npm'].depends_on = ['build'];
assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /`verify` step/);
});
-15
View File
@@ -22,12 +22,6 @@
// format | format | pnpm format:check
// test | test | pnpm test
// build | publish.yml build | pnpm build
// quality-rails | (canonical-only) | the TS quality-rails evaluator
// | | (RI-N4, QC-19 monorepo subject). Like
// | | `build`, this stage has no ci.yml
// | | mirror; it is implemented by
// | | importing the evaluator CLI rather
// | | than duplicating its presence logic.
//
// Caller-provided prerequisites (kept at the pipeline level — see the comments
// in .woodpecker/ci.yml): `bash` + `rsync` for the guard stages, `openssl` and
@@ -96,15 +90,6 @@ export const STAGES = [
name: 'build',
commands: ['pnpm build'],
},
{
// RI-N4 (QC-19, card RI-3-002): the typed quality-rails evaluator, invoked
// as the implementation of the check it owns instead of a duplicated
// presence loop here. Canonical-only stage (no ci.yml mirror — same shape
// as `build`); runs AFTER build so the evaluator's dist/ exists. Subject
// is this repository (`.` → monorepo subject kind, per-subject check set).
name: 'quality-rails',
commands: ['node packages/quality-rails/dist/cli.js quality-rails evaluate --project .'],
},
];
export function stageByName(name) {
+8 -80
View File
@@ -4,15 +4,12 @@ import { createRequire } from 'node:module';
import path from 'node:path';
import test from 'node:test';
import { STAGES, stageByName } from './verify-release.mjs';
import { STAGES } from './verify-release.mjs';
// SDLC-D-034 checkout invariant: publication in .woodpecker/publish.yml is
// bound to exact-commit terminal verification. This suite parses the real
// pipeline files and fails red when the gate is bypassed, weakened, or drifts
// out of sync with the canonical `pnpm verify:release` command. The negative
// controls for pipeline DAG/bypass shapes live in
// scripts/publish-gate-structure.test.mjs (RI-1-002); this file owns the
// canonical-command composition controls.
// out of sync with the canonical `pnpm verify:release` command.
// Reuse the monorepo's existing YAML parser (@mosaicstack/mosaic's direct
// dependency) instead of adding a root dependency or vendoring a parser.
@@ -222,28 +219,14 @@ steps:
assert.throws(() => assertPublishGate(parseYaml(noIdentityPipeline)), /CI_COMMIT_SHA/);
});
// The composition check: the canonical stage table must mirror the PR CI
// pipeline's complete mandatory set. Parameterized by the stage list so the
// subset negative control below can prove a dropped stage goes red (RI-1-002:
// the canonical command cannot silently lose a check).
function assertStagesMirrorCi(stages, ci) {
const canonical = Object.fromEntries(stages.map((stage) => [stage.name, stage.commands]));
test('the canonical verify:release stages mirror the PR CI pipeline one-for-one', async () => {
const ci = parseYaml(await readFile(ciYmlPath, 'utf8'));
const canonical = Object.fromEntries(STAGES.map((stage) => [stage.name, stage.commands]));
// The complete mandatory set, in gate order. `quality-rails` is a
// canonical-only stage (RI-N4, QC-19): like `build`, it has no ci.yml
// mirror to match — its contract is asserted separately below.
// The complete mandatory set, in gate order.
assert.deepEqual(
stages.map((stage) => stage.name),
[
'sanitization',
'upgrade-guard',
'typecheck',
'lint',
'format',
'test',
'build',
'quality-rails',
],
STAGES.map((stage) => stage.name),
['sanitization', 'upgrade-guard', 'typecheck', 'lint', 'format', 'test', 'build'],
);
// Guard stages: ci.yml commands minus its `apk add` environment prep must be
@@ -286,64 +269,9 @@ function assertStagesMirrorCi(stages, ci) {
`ci.yml test step must keep its pipeline-level prerequisite '${fragment}'`,
);
}
}
test('the canonical verify:release stages mirror the PR CI pipeline one-for-one', async () => {
const ci = parseYaml(await readFile(ciYmlPath, 'utf8'));
assertStagesMirrorCi(STAGES, ci);
});
test('a subset stage list fails the composition check — a dropped stage cannot pass silently', async () => {
const ci = parseYaml(await readFile(ciYmlPath, 'utf8'));
// Drop each stage one at a time: every stage is load-bearing, so every drop
// must go red. If any drop went green, a refactor could silently delete a
// mandatory check from the canonical command.
for (const stage of STAGES) {
const subset = STAGES.filter((entry) => entry.name !== stage.name);
assert.throws(
() => assertStagesMirrorCi(subset, ci),
Error,
`composition check must fail when the '${stage.name}' stage is dropped from the table`,
);
}
});
test('the root package.json exposes verify:release as the canonical command', async () => {
const packageJson = JSON.parse(await readFile(path.join(process.cwd(), 'package.json'), 'utf8'));
assert.match(packageJson.scripts['verify:release'], /scripts\/verify-release\.mjs/);
});
// RI-N4 (card RI-3-002): the `quality-rails` stage must route through the TS
// evaluator instead of duplicating its presence logic inline. The evaluator
// owns QC-19; this file keeps that delegation honest.
function assertEvaluatorStage(stage) {
assert.ok(stage, 'canonical stages must include a quality-rails stage');
assert.ok(Array.isArray(stage.commands) && stage.commands.length > 0);
for (const command of stage.commands) {
assert.match(
command,
/packages\/quality-rails\/dist\/cli\.js.*quality-rails evaluate/,
`quality-rails stage command must invoke the evaluator CLI, got: '${command}'`,
);
}
}
test('the quality-rails stage invokes the evaluator rather than duplicating its logic', () => {
assertEvaluatorStage(stageByName('quality-rails'));
});
test('a quality-rails stage that re-implements presence logic inline fails the checker', () => {
// Negative control: replacing the evaluator invocation with an inline
// `test -f` presence loop is exactly the duplication RI-N4 forbids — the
// checker must go red on it.
const duplicated = {
name: 'quality-rails',
commands: ['test -f .husky/pre-commit && test -f .husky/pre-push'],
};
assert.throws(() => assertEvaluatorStage(duplicated), /must invoke the evaluator CLI/);
});
test('a quality-rails stage that silently drops the evaluator command fails the checker', () => {
const empty = { name: 'quality-rails', commands: [] };
assert.throws(() => assertEvaluatorStage(empty), /commands/);
});