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
4 changes: 4 additions & 0 deletions src/operational-contract/upgrade-refusals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type UpgradeRefusalFact
| { kind: 'downgrade'; source: string; target: string }
| { kind: 'invalid-catalog'; problems: string[] }
| { kind: 'pending-workflow'; files: string[] }
| { kind: 'stale-upgrade-playbook'; file: string }
| { kind: 'git-required' }
| { kind: 'dirty-worktree'; changes: string[] }
| { kind: 'config-unreadable'; application: string; cause: string }
Expand Down Expand Up @@ -89,6 +90,9 @@ const REFUSALS: { [K in UpgradeRefusalFact['kind']]: Renderer<K> } = {
'pending-workflow': (fact) => `a Blueprint workflow is still in progress (${fact.files.join(', ')}). `
+ 'Finish the authoring or topology transformation it describes, or remove it deliberately, '
+ `then run the upgrade. ${RECOVERY}`,
'stale-upgrade-playbook': (fact) => `${fact.file} remains even though the Blueprint lifecycle `
+ 'is current. Review it and remove it manually before running upgrade again; Blueprint '
+ `preserved the file because it cannot prove whether it contains unfinished or user-edited work. ${RECOVERY}`,
'git-required': () => 'starting an upgrade requires a Git worktree so every dependency, config, '
+ `and generated-file change stays recoverable. Initialize or enter the repository first. ${RECOVERY}`,
'dirty-worktree': (fact) => `starting an upgrade requires a clean Git worktree; uncommitted: `
Expand Down
4 changes: 4 additions & 0 deletions src/operational-contract/upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@ describe('upgrade refusals', () => {
{ kind: 'installed-newer', application: 'apps/web', installed: '4.2.0', target: '4.1.0' },
'apps/web resolves @kekkai/blueprint 4.2.0, which is newer than the running 4.1.0',
],
[
{ kind: 'stale-upgrade-playbook', file: 'blueprint-upgrade.md' },
'Review it and remove it manually before running upgrade again',
],
])('explains %j', (fact, fragment) => {
expect(renderUpgradeRefusal(fact)).toContain(fragment);
});
Expand Down
75 changes: 68 additions & 7 deletions src/upgrade/decide.plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function decide(facts: Partial<UpgradeFacts>, target = '4.1.0') {
checkpoint: { kind: 'not-installed' },
git: { repository: true, changes: [] },
workflows: [],
upgradePlaybook: null,
unreadable: null,
...facts,
},
Expand All @@ -71,6 +72,22 @@ const stateCheckpoint = (state: LifecycleState) =>
({ kind: 'state', version: state.blueprint!, state }) as const;

describe('decideUpgrade · checkpoint refusals', () => {
it('preserves readiness priority when no stale current playbook exists', () => {
const workflows = ['blueprint-authoring.md'];

expect(decide({
workflows,
unreadable: { application: '.', cause: 'parse failed' },
})).toEqual({
kind: 'refuse',
refusal: { kind: 'config-unreadable', application: '.', cause: 'parse failed' },
});

expect(decide({ applications: [], workflows })).toEqual({
kind: 'refuse', refusal: { kind: 'not-adopted', root: '/repo' },
});
});

it('names only the applications that have no installed package', () => {
expect(decide({
applications: [application('apps/web', null), application('apps/admin', '4.0.0')],
Expand Down Expand Up @@ -122,6 +139,7 @@ describe('decideUpgrade · plans', () => {
applications: [application('.', '3.2.0')],
state: { status: 'present', state },
checkpoint: stateCheckpoint(state),
upgradePlaybook: 'blueprint-upgrade.md',
})).toEqual({
kind: 'proceed',
mode: 'start',
Expand Down Expand Up @@ -225,6 +243,15 @@ describe('decideUpgrade · installed package evidence', () => {
expect(decide({ ...recorded, applications: [application('.', '4.1.0')] }))
.toEqual({ kind: 'current', version: '4.1.0' });

expect(decide({
...recorded,
applications: [application('.', '4.1.0')],
upgradePlaybook: 'blueprint-upgrade.md',
})).toEqual({
kind: 'refuse',
refusal: { kind: 'stale-upgrade-playbook', file: 'blueprint-upgrade.md' },
});

expect(decide({
...recorded,
applications: [application('.', '4.1.0'), application('apps/web', null)],
Expand All @@ -234,17 +261,50 @@ describe('decideUpgrade · installed package evidence', () => {
});

for (const installed of ['4.0.0', null]) {
expect(decide({ ...recorded, applications: [application('.', installed)] })).toMatchObject({
kind: 'proceed',
mode: 'start',
source: '4.1.0',
target: '4.1.0',
pending: { from: '4.1.0', to: '4.1.0', operations: [] },
installs: [{ manifest: '.', command: 'npm install -D @kekkai/blueprint@4.1.0' }],
expect(decide({
...recorded,
applications: [application('.', installed)],
upgradePlaybook: 'blueprint-upgrade.md',
})).toEqual({
kind: 'refuse',
refusal: { kind: 'stale-upgrade-playbook', file: 'blueprint-upgrade.md' },
});
}
});
});

describe('decideUpgrade · stale residue precedence', () => {
it('preserves readiness priority for active workflows beside stale residue', () => {
const state = lifecycle({ blueprint: '4.1.0' });

const recorded = {
state: { status: 'present' as const, state },
checkpoint: stateCheckpoint(state),
workflows: ['blueprint-authoring.md'],
upgradePlaybook: 'blueprint-upgrade.md',
};

expect(decide(recorded)).toEqual({
kind: 'refuse',
refusal: { kind: 'pending-workflow', files: ['blueprint-authoring.md'] },
});

expect(decide({
...recorded,
unreadable: { application: '.', cause: 'parse failed' },
})).toEqual({
kind: 'refuse',
refusal: { kind: 'config-unreadable', application: '.', cause: 'parse failed' },
});

expect(decide({ ...recorded, applications: [] })).toEqual({
kind: 'refuse',
refusal: { kind: 'not-adopted', root: '/repo' },
});
});
});

describe('decideUpgrade · installed package conflicts', () => {
it('refuses to move an application back to an older running package', () => {
const state = lifecycle({ blueprint: '4.1.0' });

Expand Down Expand Up @@ -274,6 +334,7 @@ describe('decideUpgrade · installed package evidence', () => {
it('establishes state for an unrecorded adoption already on the running release', () => {
expect(decide({
checkpoint: { kind: 'bootstrap', version: '4.1.0', evidence: 'installed-package' },
upgradePlaybook: 'blueprint-upgrade.md',
})).toEqual({
kind: 'proceed',
mode: 'start',
Expand Down
2 changes: 2 additions & 0 deletions src/upgrade/decide.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe('decideUpgrade · defense against a resolution the catalog check did no
checkpoint: { kind: 'bootstrap', version: '4.0.0', evidence: 'installed-package' },
git: { repository: true, changes: [] },
workflows: [],
upgradePlaybook: null,
unreadable: null,
};

Expand Down Expand Up @@ -62,6 +63,7 @@ describe('decideUpgrade · defense against a resolution the catalog check did no
},
git: { repository: true, changes: [] },
workflows: [],
upgradePlaybook: null,
unreadable: null,
};

Expand Down
23 changes: 22 additions & 1 deletion src/upgrade/decide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,27 @@ function readinessRefusal(input: DecisionInput): UpgradeDecision | null {
: null;
}

function stalePlaybookRefusal(facts: UpgradeFacts, target: string): UpgradeDecision | null {
if (facts.state.status !== 'present') {
return null;
}

const { state } = facts.state;

return state.blueprint === target && state.pending === null
&& facts.upgradePlaybook !== null
? refuse({ kind: 'stale-upgrade-playbook', file: facts.upgradePlaybook })
: null;
}

function preflightRefusal(input: DecisionInput, target: string): UpgradeDecision | null {
const stale = stalePlaybookRefusal(input.facts, target);

return stale !== null && !input.facts.workflows.length
? stale
: readinessRefusal(input);
}

function installedRefusal(facts: UpgradeFacts, target: string): UpgradeDecision | null {
const installed = facts.applications
.flatMap((entry) => entry.installed === null ? [] : [entry]);
Expand Down Expand Up @@ -170,7 +191,7 @@ export function decideUpgrade(input: DecisionInput): UpgradeDecision {
}

const refusal = catalogRefusal(input, running.version)
?? readinessRefusal(input)
?? preflightRefusal(input, running.version)
?? checkpointRefusal(facts)
?? historyRefusal(facts, input.catalog);

Expand Down
13 changes: 13 additions & 0 deletions src/upgrade/facts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ describe('gatherUpgradeFacts', () => {
});

expect(facts.git).toEqual({ repository: true, changes: ['a.ts', 'docs/b.md'] });
expect(facts.upgradePlaybook).toBeNull();
});

it('reports a repository-root upgrade playbook separately from active workflows', async () => {
adopt('.');
write('blueprint-upgrade.md', '# stale\n');

const facts = await gatherUpgradeFacts(root, {
git: git(''), loadConfig: async () => CURRENT, catalog: UPGRADE_CATALOG,
});

expect(facts.upgradePlaybook).toBe('blueprint-upgrade.md');
expect(facts.workflows).toEqual([]);
});

it('treats a config without an architecture as current', async () => {
Expand Down
5 changes: 5 additions & 0 deletions src/upgrade/facts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
manifestOwner,
readLifecycleState,
sourceCheckpoint,
UPGRADE_PLAYBOOK_FILE,
} from '../lifecycle';
import type {
ApplicationFacts,
Expand Down Expand Up @@ -50,6 +51,7 @@ export interface UpgradeFacts {
checkpoint: SourceCheckpoint;
git: { repository: boolean; changes: string[] };
workflows: string[];
upgradePlaybook: string | null;
unreadable: { application: string; cause: string } | null;
}

Expand Down Expand Up @@ -158,6 +160,9 @@ export async function gatherUpgradeFacts(cwd: string, effects: FactEffects): Pro
}),
git: gitFacts(root, git),
workflows: workflows(root, applications),
upgradePlaybook: fs.existsSync(path.join(root, UPGRADE_PLAYBOOK_FILE))
? UPGRADE_PLAYBOOK_FILE
: null,
unreadable,
};
}
Loading
Loading