Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,16 @@ consumed here as released packages.
| -- | -- | -- | -- |
| [`packages/cli/`](./packages/cli/) | `@workspacejson/cli` | `0.5.2` | the neutral producer and its `workspacejson` binary |
| [`packages/agents-audit-compat/`](./packages/agents-audit-compat/) | `agents-audit` | `0.4.4` | frozen compatibility bridge; preserves the historical command and API |
| [`packages/mining-core/`](./packages/mining-core/) | `@workspacejson/mining-core` | `0.0.0`, private | L0 commit-graph mining core — extraction, path identity, completeness semantics (META-297 Phases 1–2) |

Those two packages are the whole repository. The private DataHub/dbt adapter
`mining-core` is private and unpublished. It reads git and returns an in-memory
observation set; it does not write the artifact. Projecting into
`generated.coChange` is a separate, later step that is blocked on a schema
admission — the published `coChange` item requires `rate` and forbids additional
properties, so the counts-only shape the churn ruling calls for is rejected by
the schema rather than merely different from it.

The published packages are the first two. The private DataHub/dbt adapter
that was staged here has been **extracted to `workspacejson/datahub-agent`**
(META-248), which owns DataHub consumption; it was never durable architecture
here. The boundary is machine-enforced and red-tested — see
Expand Down
462 changes: 462 additions & 0 deletions packages/cli/candidate-tests/l1-integration.test.mjs

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@workspacejson/cli",
"version": "0.5.2",
"description": "The workspace.json producer scans a repository and generates .agents/workspace.json deterministically, preserving human-authored manual evidence.",
"description": "The workspace.json producer \u2014 scans a repository and generates .agents/workspace.json deterministically, preserving human-authored manual evidence.",
"license": "Apache-2.0",
"author": "workspace.json contributors",
"homepage": "https://workspacejson.dev",
Expand Down Expand Up @@ -66,8 +66,9 @@
},
"devDependencies": {
"@types/node": "22.19.17",
"typescript": "^5.4.0",
"@workspacejson/mining-core": "workspace:*",
"tsup": "^8.0.0",
"typescript": "^5.4.0",
"vitest": "^1.6.0"
}
}
2 changes: 1 addition & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export {
GenerateRefusalError,
THIS_PRODUCER,
} from './producer/generate.js';
export type { GenerateResult, ProducerIdentity } from './producer/generate.js';
export type { GenerateResult, HistoryRefreshOutcome, ProducerIdentity } from './producer/generate.js';
export { DEFAULT_PRODUCER_CONFIG, detectCiProvider } from './producer/config.js';
export type { ProducerConfig } from './producer/config.js';
export { findAgentsMdPath, readTextOrEmpty } from './producer/fs.js';
Expand Down
130 changes: 129 additions & 1 deletion packages/cli/src/producer/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,30 @@ import type { RuleContext } from '@workspacejson/rules';
import { DEFAULT_PRODUCER_CONFIG, detectCiProvider, type ProducerConfig } from './config.js';
import { buildFileIndex, buildFrameworkManifest } from './evidence.js';
import { findAgentsMdPath, readTextOrEmpty } from './fs.js';
import { carryForwardHistory, type PreservedHistory } from './history-carry-forward.js';
import { mineHistoryBlock } from './history-mine.js';

/**
* A history block from either route: freshly mined, or carried forward.
*
* Declared locally because the two sources carry different TYPES for the same
* runtime shape, and the mismatch is a fact about the dependency rather than
* about this code. `@workspacejson/spec@0.4.4` is the published package, and
* its `CoChangeEntry` predates ADR-003 A-009: it still requires `rate` and
* knows nothing of `support`. So an observation-form entry — exactly what this
* producer now emits — is not assignable to the published type, and cannot be
* until the amended spec is published.
*
* The runtime contract is unaffected and is NOT relaxed anywhere: the artifact
* still goes through `WorkspaceJsonValidator` unmodified, and the candidate
* conformance suite runs it against the amended schema. This declaration
* narrows a compile-time gap in a stale type; it does not widen what the
* producer will accept or emit. It is deleted when the amended spec publishes.
*/
interface HistoryBlock {
basisRevision: string;
coChange: readonly unknown[];
}

const _require = createRequire(import.meta.url);

Expand Down Expand Up @@ -63,13 +87,46 @@ export interface ProducerIdentity {

export const THIS_PRODUCER: ProducerIdentity = { name: pkgName, version: pkgVersion };

/**
* What an explicitly requested history refresh actually did.
*
* Present only when the caller passed `mineHistory: true`, so its absence means
* no refresh was requested rather than a refresh that failed.
*
* The distinction this exists to make: a refused refresh still produces a
* successful generation carrying the PREVIOUS revision's counts, because
* destroying evidence over a shallow clone or a transient Git failure would be
* worse than keeping it. That is the right behavior and it is also
* indistinguishable, from the artifact alone, from a refresh that completed.
* A caller that asked for fresh observations must be able to tell.
*/
export interface HistoryRefreshOutcome {
/** Always true — the field is absent unless a refresh was requested. */
requested: true;
/** True when the commit graph was read and a new block produced. */
mined: boolean;
/** True when mining refused and a prior block was carried instead. */
preserved: boolean;
/**
* Why mining produced nothing. Present if and only if `mined` is false —
* e.g. a shallow clone, absent history, or a Git invocation failure.
*/
refusal?: string;
}

export interface GenerateResult {
path: string;
written: boolean;
skipped: boolean;
drift: boolean;
preservedManual: boolean;
invalidFileMoved?: string;
/**
* Present only when `mineHistory: true` was requested. Says whether the
* refresh completed, and why not when it did not — so a refused refresh
* cannot read as a successful one.
*/
historyRefresh?: HistoryRefreshOutcome;
content: WorkspaceJsonV4;
}

Expand Down Expand Up @@ -129,7 +186,22 @@ export async function writeWorkspaceAtomically(outputPath: string, content: Work
export async function generateWorkspaceJson(
repoRoot: string,
config: Partial<ProducerConfig> = {},
options: { dryRun?: boolean; check?: boolean; force?: boolean; producer?: ProducerIdentity; commandName?: string } = {},
options: {
dryRun?: boolean;
check?: boolean;
force?: boolean;
producer?: ProducerIdentity;
commandName?: string;
/**
* Read the commit graph and rewrite `generated.coChange`.
*
* Off by default, and that default is the contract rather than a
* convenience: mining a bounded window costs seconds to tens of seconds,
* and a producer that recomputed history on every ordinary run would make
* the artifact churn on every commit. See history-carry-forward.ts.
*/
mineHistory?: boolean;
} = {},
): Promise<GenerateResult> {
const resolvedRoot = resolve(repoRoot);
const fullConfig: ProducerConfig = { ...DEFAULT_PRODUCER_CONFIG, ...config };
Expand Down Expand Up @@ -208,6 +280,43 @@ export async function generateWorkspaceJson(
}
}
}
// Commit-history evidence enters the artifact by exactly one of two routes,
// and never both. Mining is EXPLICIT: `mineHistory` is off unless a caller
// asked for it, so an ordinary run reads the working tree and nothing else.
//
// The order matters. A refused mining pass falls back to carry-forward rather
// than to nothing: a shallow clone or a git failure must not destroy evidence
// an earlier successful pass recorded.
//
// But falling back QUIETLY is its own defect, and a worse one. A caller that
// asked for a refresh and received a successful-looking result carrying the
// previous revision's counts cannot tell that from a refresh that completed —
// the artifact looks the same either way, and `basisRevision` only helps a
// reader who already suspects something. So the outcome is reported on the
// result: `historyRefresh` says whether the refresh actually happened, and
// carries the refusal reason when it did not.
//
// The reason itself was already being computed and thrown away — the
// diagnostics object exists for exactly this and was not passed.
const refreshDiagnostics: { refusal?: string } = {};
const minedHistory =
options.mineHistory === true ? await mineHistoryBlock(resolvedRoot, refreshDiagnostics) : undefined;
const preservedHistory = carryForwardHistory(existing);
const history: HistoryBlock | undefined =
minedHistory ?? (preservedHistory.preserved ? preservedHistory.history : undefined);

const historyRefresh: HistoryRefreshOutcome | undefined =
options.mineHistory === true
? {
requested: true,
mined: minedHistory !== undefined,
preserved: minedHistory === undefined && preservedHistory.preserved,
...(minedHistory === undefined
? { refusal: refreshDiagnostics.refusal ?? 'mining produced no history block' }
: {}),
}
: undefined;

const workspace: WorkspaceJsonV4 = {
manual: existing?.manual ?? {},
generated: {
Expand Down Expand Up @@ -251,6 +360,24 @@ export async function generateWorkspaceJson(
scannedAt:
(existing?.generated.hygiene as { scannedAt?: string } | undefined)?.scannedAt ?? now,
},
// Commit-history evidence is PRESERVED, never rebuilt, by ordinary
// generation — see history-carry-forward.ts for why this one part of the
// producer-owned section is carried rather than regenerated.
//
// The values are spliced in as the objects parsed from the prior
// artifact, so the bytes are unchanged. Nothing here reads the commit
// graph: no mining, no pin advance, no re-attribution of old counts to a
// newer revision. If nothing conforming was preserved, both keys stay
// absent — ordinary generation never invents a history block, and an
// absent block correctly reads as "not analyzed".
...(history === undefined
? {}
: {
basisRevision: history.basisRevision,
// See HistoryBlock: the published 0.4.4 type cannot describe an
// observation-form entry. The value is validated at runtime.
coChange: history.coChange as NonNullable<WorkspaceJsonV4['generated']['coChange']>,
}),
},
agents: {},
health: {
Expand All @@ -276,6 +403,7 @@ export async function generateWorkspaceJson(
drift: !unchanged,
preservedManual: existing !== undefined,
...(invalidFileMoved === undefined ? {} : { invalidFileMoved }),
...(historyRefresh === undefined ? {} : { historyRefresh }),
content: workspace,
};
}
118 changes: 118 additions & 0 deletions packages/cli/src/producer/history-carry-forward.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* Carry-forward semantics — the pure decision function.
*
* Repo-native and validator-free by construction. This file runs inside the CLI
* workspace against its legitimate published `@workspacejson/spec@0.4.4` and
* `@workspacejson/rules@0.4.4` dependencies, and must keep passing there, so it
* touches nothing that needs the amended schema. The end-to-end cases — which
* DO need a validator that accepts the observation form — live in
* `candidate-tests/` and run only in the packed-candidate environment.
*
* Every case here is written so that removing the behaviour it covers makes it
* fail. That is the whole value: the three failure modes carry-forward exists
* to prevent (drop, advance the pin, recompute) all produce a *plausible*
* artifact, so nothing about the output looks wrong when they happen. Only a
* test that knows what the previous artifact said can tell.
*/
import { describe, expect, it } from 'vitest';
import { CarryForwardRefusal, carryForwardHistory } from './history-carry-forward.js';

const BASIS = '3c9a0f14b7e25d8613af04c2e9b7d5081f6a2c3d';

const observationEntry = (over: Record<string, unknown> = {}) => ({
files: ['src/auth.ts', 'src/session.ts'],
support: 8,
occurrences: 24,
...over,
});

/** A prior artifact carrying mined evidence, as `generate` would find on disk. */
function priorArtifact(over: Record<string, unknown> = {}): Record<string, unknown> {
return {
manual: { fragileFiles: ['src/auth.ts'] },
generated: {
specVersion: '0.4',
generatedAt: '2026-06-01T00:00:00Z',
basisRevision: BASIS,
by: { name: '@workspacejson/cli', version: '0.5.2' },
frameworkManifest: [],
fileIndex: {},
coChange: [observationEntry(), observationEntry({ files: ['a.ts', 'b.ts'], support: 3, occurrences: 9 })],
...over,
},
agents: {},
health: { intelligenceState: 'INSUFFICIENT_DATA', observationCount: 0, confidence: 0 },
};
}

describe('carryForwardHistory — what ordinary generation preserves', () => {
it('preserves a conforming observation block and its pin', () => {
const result = carryForwardHistory(priorArtifact() as never);
expect(result.preserved).toBe(true);
if (!result.preserved) return;
expect(result.history.basisRevision).toBe(BASIS);
expect(result.history.coChange).toHaveLength(2);
});

it('passes the parsed entries THROUGH rather than rebuilding them', () => {
// Byte-for-byte preservation is the contract. Rebuilding an entry field by
// field would re-order its keys and change the serialized bytes even though
// the value is structurally identical, so identity of the array elements is
// the property that actually guarantees it.
const prior = priorArtifact();
const original = (prior['generated'] as Record<string, unknown>)['coChange'] as unknown[];
const result = carryForwardHistory(prior as never);
expect(result.preserved).toBe(true);
if (!result.preserved) return;
expect(result.history.coChange[0]).toBe(original[0]);
expect(result.history.coChange[1]).toBe(original[1]);
});

it('preserves a PINNED EMPTY array — a positive finding, not an absence', () => {
// "The analysis ran and found no qualifying pairs" is evidence. Dropping it
// would silently convert it into "never analyzed".
const result = carryForwardHistory(priorArtifact({ coChange: [] }) as never);
expect(result.preserved).toBe(true);
});

it('refuses when there is no prior artifact — never invents a block', () => {
const result = carryForwardHistory(undefined);
expect(result.preserved).toBe(false);
if (result.preserved) return;
expect(result.refusal).toBe(CarryForwardRefusal.NO_PRIOR_BLOCK);
});

it('refuses a legacy rate entry rather than perpetuating it', () => {
const result = carryForwardHistory(
priorArtifact({ coChange: [{ files: ['a.ts', 'b.ts'], rate: 0.8, occurrences: 9, generated: false }] }) as never,
);
expect(result.preserved).toBe(false);
if (result.preserved) return;
expect(result.refusal).toBe(CarryForwardRefusal.NOT_OBSERVATION_FORM);
});

it('refuses an observation block whose pin is symbolic or abbreviated', () => {
for (const basisRevision of ['HEAD', 'main', BASIS.slice(0, 7), BASIS.toUpperCase()]) {
const result = carryForwardHistory(priorArtifact({ basisRevision }) as never);
expect(result.preserved).toBe(false);
if (result.preserved) continue;
expect(result.refusal).toBe(CarryForwardRefusal.NO_CONFORMING_BASIS);
}
});

it('carries an entry that omits the A-010 classification flag', () => {
// The shape this producer emits. Treating the absent flag as malformed
// would refuse to carry forward exactly its own output.
const result = carryForwardHistory(priorArtifact() as never);
expect(result.preserved).toBe(true);
if (!result.preserved) return;
expect('generated' in (result.history.coChange[0] as object)).toBe(false);
});

it('refuses a block violating support <= occurrences', () => {
const result = carryForwardHistory(
priorArtifact({ coChange: [observationEntry({ support: 30, occurrences: 24 })] }) as never,
);
expect(result.preserved).toBe(false);
});
});
Loading
Loading