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
62 changes: 62 additions & 0 deletions docs/ci/continuation/ADAPTERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Repository adapters and conservative onboarding

Date: 2026-09-10. Parent: [engineering contract](README.md). The portable CLI and Taskdeck adapter produce observations only; neither executes the task commands it models, writes GitHub checks, edits workflows, or enables result reuse.

## Portable CLI

Node 22+ and Git are required. There are no external package dependencies. Run a reviewed copy of this directory from trusted tooling; `--repo` is an object database to inspect, not a source of executable plugins.

```sh
node scripts/ci/smart-ci/continuation/cli.mjs init --kind node --repository-id 123456 --root web --out candidate-config.json
node scripts/ci/smart-ci/continuation/cli.mjs validate --manifest candidate-config.json
```

Supported starter ecosystems are `node`, `dotnet` and `python`. The root is a literal repository-relative directory, or `.`. Starters deliberately model a complete component suite with `reviewed: false`. The sample command is a reviewable identity, not a promise that every repository exposes that script. Adapt working directory, exact command, runtime and dependency resolution to the actual workflow. Add transitive shared-code/configuration/fixture dependencies before narrowing anything.

After review, commit the configuration at `.ci/continuation.json` on the trusted base. Obtain the immutable numeric repository ID from authenticated provider metadata, not from candidate-authored configuration. Then:

```sh
node scripts/ci/smart-ci/continuation/cli.mjs plan --repo /path/to/repository --base FULL_BASE_COMMIT_SHA --candidate FULL_CANDIDATE_COMMIT_SHA --repository-id 123456 --out advisory.json
```

`--config` may select another file only under a protected control path. The CLI reads configuration bytes from the **base commit**, never the candidate or dirty working tree. Candidate edits to the control policy select full qualification. Missing Git objects, invalid JSON, repository mismatches and incomplete inventories fail visibly. The output is a new file; existing output files are never overwritten.

The source API `adviseRepository` accepts an additive canonical selection floor, exact environments/context and event type. `inspectRepository` is the immutable-object, observation-only command path. Non-PR events, explicit full qualification, unknown ownership or changed control paths escalate. Default output has `authority: none` and all executable actions remain `run`.

## Manifest protocol

`ci.repository-adapter.v1` contains an immutable repository ID, human description, declarative policy, and the core input-contract graph. It contains no JavaScript plugin or shell hook.

The policy declares `alwaysTasks`, `controlPaths`, and ownership `rules`. The minimum `.github/**`, `.ci/**`, `ci/**` and `scripts/ci/**` control coverage cannot be removed. Rules may add checks; the canonical floor cannot be reduced. Input closure may add further dependent consumers. Unknown top-level/policy fields, duplicate task references, unsupported patterns and cyclic dependencies are rejected.

The contract graph declares components, transitive dependencies, task input patterns, required files, command identity, platform, exact environment/context keys, TTL and review/reuse state. See the parent engineering document for fingerprint and evidence semantics. The adapter policy and complete contract content affect the fingerprint; editing configuration invalidates applicable observations rather than silently retaining earlier approval.

For a monorepo, create distinct components for shared libraries and each deployable/service, then declare tests and integration tasks over their transitive inputs. For a single application, keep one complete task until measured costs justify a split. Never assume that folder names alone capture test harnesses, generated clients, containers, process launchers, imported build targets or external data.

## Taskdeck-specific bridge

```sh
node scripts/ci/smart-ci/continuation/adapters/taskdeck.mjs --repo /path/to/Taskdeck --plan ci-plan.json --out taskdeck-advisory.json
```

Run the bridge from reviewed Taskdeck tooling. It imports canonical validators relative to its own trusted module, **not** from the `--repo` candidate checkout. It reads `ci/policy.v1.json` from the plan's immutable control-base SHA. Canonical plan/schema validation happens before the auxiliary report. The comparison is bound to the observed merge first parent and exact merge commit/tree.

Every Taskdeck task contract is still explicitly unreviewed. The adapter derives all canonical lane IDs instead of inventing a parallel policy. It only adds affected lanes; risk/trust escalation remains full. The initial broad contracts account for the Linux frontend job's backend-launcher dependency. Unknown future lane IDs receive a whole-repository input boundary, not an optimistic empty contract.

This bridge is not evidence of safe omission. A local invocation can inspect a supplied plan, but production acceptance still requires authenticated canonical provenance. No report produced here is fed into the canonical gate as successful task evidence.

## Dependency-only workflow proposal

```sh
node scripts/ci/smart-ci/continuation/tools/stage-taskdeck.mjs --repo /path/to/Taskdeck --out ci-required.proposed.yml --mode minimal
```

The transformer handles the reviewed 13-job block-mapping shape only. It changes `needs`, retaining commands, matrices, names, permissions, immutable pins and event triggers. It refuses unfamiliar structures, anchors, cycles, duplicate dependencies and unconditional dependence on the PR-only secret scan. Apply only the reviewed diff in a separate PR. The optional `compute` mode waits for the entire backend unit matrix before API integration; this is not the default because it may worsen healthy-candidate latency.

The transformer is intentionally **Taskdeck-specific**. Do not use it as a generic YAML migration engine. Other repositories should use their own reviewed workflow adapter over the provider-neutral contracts/core.

## Validation and limitations

Combined core + original Taskdeck/staging + new repository adapter/CLI tests: **253 passed, zero failed/skipped/cancelled**, local Node 22.16.0/Linux. A real temporary Git repository proves candidate and dirty-worktree policy changes cannot replace the base policy; output overwrite and configuration-budget checks are exercised. These fixtures do not constitute adoption in a second real repository, hosted configured-Node qualification, Windows testing or a full Taskdeck governance/product pass.

Porting requires a second real-repository shadow trial before claiming cross-repository effectiveness. No new repository, external service, registry publication or license grant is created. The kit inherits the repository license; separately review licensing before distributing it as a standalone product.
2 changes: 2 additions & 0 deletions scripts/ci/smart-ci/continuation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ import './continuation/tests/core.test.mjs';
import './continuation/tests/evidence.test.mjs';
import './continuation/tests/execution.test.mjs';
import './continuation/tests/planner.test.mjs';
import './continuation/tests/adapter.test.mjs';
import './continuation/tests/repository.test.mjs';
98 changes: 98 additions & 0 deletions scripts/ci/smart-ci/continuation/adapters/repository.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { execFileSync } from 'node:child_process';
import { TextDecoder } from 'node:util';
import { validateContracts } from '../core/contracts.mjs';
import { changedPaths, snapshot } from '../core/snapshot.mjs';
import { planContinuation } from '../core/planner.mjs';
import { glob, hash, invariant, isGitId, matches, validPath } from '../core/primitives.mjs';

export const CONTROL_FLOOR = ['.github/**', '.ci/**', 'ci/**', 'scripts/ci/**'];
const uniqueStrings = value => Array.isArray(value) && value.every(x => typeof x === 'string' && x.length > 0) && new Set(value).size === value.length;

/** A small declarative adapter protocol, not an executable plugin loaded from the PR. */
export function validateManifest(manifest) {
invariant(manifest?.format === 'ci.repository-adapter.v1', 'unsupported repository adapter');
invariant(typeof manifest.repositoryId === 'string' && /^[1-9]\d*$/.test(manifest.repositoryId), 'numeric immutable repository ID required');
invariant(typeof manifest.description === 'string', 'adapter description required');
invariant(Object.keys(manifest).every(k => ['format', 'repositoryId', 'description', 'policy', 'contracts'].includes(k)), 'unknown adapter field');
validateContracts(manifest.contracts);
const tasks = Object.keys(manifest.contracts.tasks), policy = manifest.policy;
invariant(policy && Object.keys(policy).every(k => ['alwaysTasks', 'controlPaths', 'rules'].includes(k)), 'invalid adapter policy');
invariant(uniqueStrings(policy.alwaysTasks) && policy.alwaysTasks.every(id => tasks.includes(id)), 'invalid always tasks');
invariant(uniqueStrings(policy.controlPaths) && CONTROL_FLOOR.every(p => policy.controlPaths.includes(p)), 'protected control floor cannot be removed');
policy.controlPaths.forEach(glob);
invariant(Array.isArray(policy.rules) && policy.rules.length > 0, 'explicit ownership rules required');
for (const rule of policy.rules) {
invariant(rule && Object.keys(rule).every(k => ['patterns', 'tasks'].includes(k)), 'invalid ownership rule');
invariant(uniqueStrings(rule.patterns) && rule.patterns.length > 0, 'invalid rule patterns');
rule.patterns.forEach(glob);
invariant(uniqueStrings(rule.tasks) && rule.tasks.every(id => tasks.includes(id)), 'unknown or duplicate rule task');
}
return manifest;
}

/** Read exact blob bytes from a complete Git tree. No checkout, hooks, project commands or imports. */
export function readJsonBlob(repo, commit, path, maxBytes = 1024 * 1024) {
invariant(isGitId(commit) && validPath(path), 'exact commit and safe repository-relative path required');
invariant(Number.isSafeInteger(maxBytes) && maxBytes > 0 && maxBytes <= 8 * 1024 * 1024, 'invalid blob budget');
const state = snapshot(repo, commit), entry = state.entries.find(e => e.path === path);
invariant(entry && ['100644', '100755'].includes(entry.mode), 'configuration must be a regular tracked blob');
const bytes = execFileSync('git', ['--no-replace-objects', '-C', repo, 'cat-file', 'blob', entry.oid],
{ timeout: 30000, maxBuffer: maxBytes, env: { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1', GIT_ALLOW_PROTOCOL: '' } });
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
return { value: JSON.parse(text), text, oid: entry.oid, commit, path };
}

/** Always observation. An existing protected selector may supply additional mandatory tasks. */
export function adviseRepository({ manifest, baseState, candidateState, canonicalSelected = [], environments = {}, context = {}, event = 'pull_request', forceFull = false }) {
validateManifest(manifest);
const universe = Object.keys(manifest.contracts.tasks).sort(), paths = changedPaths(baseState, candidateState);
invariant(uniqueStrings(canonicalSelected) && canonicalSelected.every(id => universe.includes(id)), 'invalid canonical floor');
const selected = new Set([...manifest.policy.alwaysTasks, ...canonicalSelected]);
const reasons = [];
if (forceFull) reasons.push('explicit-full');
if (event !== 'pull_request') reasons.push('non-pr-event');
for (const path of paths) {
if (matches(path, manifest.policy.controlPaths)) reasons.push('control-path');
const rules = manifest.policy.rules.filter(rule => matches(path, rule.patterns));
if (!rules.length && !matches(path, manifest.policy.controlPaths)) reasons.push('unmapped-path');
for (const rule of rules) for (const id of rule.tasks) selected.add(id);
}
const fullQualification = reasons.length > 0;
const plan = planContinuation({ universe, graph: manifest.contracts,
canonicalSelected: fullQualification ? universe : [...selected].sort(), baseState, candidateState,
mode: 'observe', qualifiedTasks: [], environments, context, repositoryId: manifest.repositoryId,
policyDigest: hash({ domain: 'ci.repository-adapter.v1', manifest }), fullQualification });
return { format: 'ci.repository-advisory.v1', authority: 'none', configPolicyDigest: hash(manifest),
escalationReasons: [...new Set(reasons)].sort(), changedPathCount: paths.length, plan };
}

/** The policy is always read from the caller's trusted base, NEVER the candidate or worktree. */
export function inspectRepository({ repo, base, candidate, configPath = '.ci/continuation.json', repositoryId }) {
invariant(validPath(configPath) && matches(configPath, CONTROL_FLOOR), 'config must live under a protected control path');
const config = readJsonBlob(repo, base, configPath);
invariant(config.value.repositoryId === repositoryId, 'repository binding mismatch');
const report = adviseRepository({ manifest: config.value, baseState: snapshot(repo, base), candidateState: snapshot(repo, candidate) });
return { ...report, configSource: { commit: base, path: configPath, blob: config.oid } };
}

/** Conservative starters: commands are identities for review, not executed by this kit. */
export function starterManifest({ repositoryId, kind, root = '.' }) {
invariant(['node', 'dotnet', 'python'].includes(kind), 'kind must be node, dotnet or python');
invariant(root === '.' || validPath(root) && !/[*?\[\]{}!]/.test(root), 'invalid project root');
const prefix = root === '.' ? '' : `${root}/`;
const specs = {
node: { command: ['npm', 'test', '--', '--run'], required: [`${prefix}package.json`], runtime: 'node' },
dotnet: { command: ['dotnet', 'test', '--configuration', 'Release'], required: [`${prefix}**/*.csproj`], runtime: 'dotnet' },
python: { command: ['python', '-m', 'pytest'], required: [`${prefix}pyproject.toml`], runtime: 'python' }
};
const spec = specs[kind];
return validateManifest({ format: 'ci.repository-adapter.v1', repositoryId,
description: `Review this ${kind} starter against actual workflows, working directory, fixtures and dependencies before narrowing it.`,
policy: { alwaysTasks: [], controlPaths: [...CONTROL_FLOOR], rules: [{ patterns: [`${prefix}**`], tasks: ['tests'] }, { patterns: ['docs/**', '**/*.md'], tasks: [] }] },
contracts: { version: 1, globalInputs: [...CONTROL_FLOOR, '**/package.json', '**/package-lock.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/global.json', '**/Directory.Build.*', '**/Directory.Packages.props', '**/NuGet.Config', '**/nuget.config', '**/pyproject.toml', '**/requirements*.txt', '**/uv.lock', '**/poetry.lock', '.gitattributes', '.gitmodules'],
components: { product: { inputs: [`${prefix}**`], deps: [] } },
tasks: { tests: { components: ['product'], inputs: [], requiredInputs: spec.required,
command: ['working-directory', root, ...spec.command], platform: 'linux-x64',
environmentKeys: ['os', 'arch', 'image', spec.runtime, 'dependencies'], contextKeys: ['configuration'],
reuse: 'eligible', reviewed: false, ttlSeconds: 86400 } } } });
}
Loading
Loading