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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ Older projects without a baseline fall back to the conservative rule: anything
that differs is preserved for review. `--json` reports the classification and
`baselineAvailable` for automation.

**Honest provenance.** After an upgrade, `packkit.json` records what actually
happened rather than claiming the project is a fresh scaffold of the new
version. `version` (the version you generated with) is left untouched;
`lastUpgradeAppliedWith` records the version applied, and `upgradeStatus` is
`current` only when nothing was left behind — a partial upgrade that preserved
your edits is marked `partial` with an `unresolvedChanges` count.

## Or configure it on the web

No install needed: **[danmat.github.io/create-packkit](https://danmat.github.io/create-packkit/)** — tick the options, preview the file tree, and **download a zip** (or copy the equivalent `npx create-packkit` command). Everything runs in your browser.
Expand Down
46 changes: 45 additions & 1 deletion src/embedded/upgrade.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,12 @@ export function buildUpgradeWrite({ generated, onDisk, plan, policy } = {}) {
const merged = mergePackageJson(onDisk['package.json'], generated['package.json'], plan.packageJson, p);
if (merged !== null) out['package.json'] = merged;
}
if (plan.provenanceOutdated && generated['packkit.json']) out['packkit.json'] = generated['packkit.json'];
// Refresh packkit.json honestly: new baseline, but marked partial (with a
// count) when the policy leaves changes unresolved — never implying the
// project fully matches the new version when it doesn't.
if (plan.provenanceOutdated && generated['packkit.json']) {
out['packkit.json'] = upgradedProvenance(onDisk['packkit.json'], generated['packkit.json'], countUnresolved(plan, p));
}
return out;
}

Expand Down Expand Up @@ -220,6 +225,45 @@ function mergePackageJson(diskStr, genStr, pkgPlan, policy) {
return toJson(finalizePackageJson(merged));
}

// How many changed items the policy leaves unresolved (preserved, not applied).
function countUnresolved(plan, policy) {
let n = 0;
for (const path of plan.files.changed) if (!willApply(policy.files, plan.files.entries[path])) n++;
for (const c of Object.values(plan.packageJson.changedScripts)) if (!willApply(policy.scripts, c)) n++;
for (const section of DEP_SECTIONS) for (const c of Object.values(plan.packageJson.changedDependencies[section])) if (!willApply(policy.dependencies, c)) n++;
for (const c of plan.packageJson.changedFields) if (!willApply(policy.packageFields, c)) n++;
return n;
}

// Build the packkit.json to write after an upgrade. Its baseline moves to the
// current-version generated state (so preserved user edits are correctly seen
// as customizations next time), but it does NOT claim the project was generated
// with the new version — `version` (generatedWith) stays original, and upgrade
// tracking fields record what actually happened. A partial upgrade is marked as
// such rather than looking fully current.
function upgradedProvenance(diskStr, genStr, unresolved) {
let disk = {};
let gen;
try {
gen = JSON.parse(genStr);
if (diskStr) disk = JSON.parse(diskStr);
} catch {
return genStr; // can't reason about it — fall back to the regenerated file
}
const toVersion = gen.version;
const out = {
...gen,
version: disk.version || gen.version, // generatedWith: unchanged by an upgrade
};
if (toVersion) {
out.lastUpgradeCheckedWith = toVersion;
out.lastUpgradeAppliedWith = toVersion;
}
out.upgradeStatus = unresolved > 0 ? 'partial' : 'current';
if (unresolved > 0) out.unresolvedChanges = unresolved;
return toJson(out);
}

// Structural package.json diff, three-way-classified against the baseline
// snapshot when present.
function diffPackageJson(diskStr, genStr, baseline) {
Expand Down
23 changes: 22 additions & 1 deletion test/upgrade.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,15 @@ test('default apply: adds new, preserves every differing value', () => {

assert.equal(write['new.txt'], 'fresh', 'new file added');
assert.equal(write['edited.txt'], undefined, 'changed file preserved (not written)');
assert.equal(write['packkit.json'], generated['packkit.json'], 'provenance refreshed');

// Provenance is refreshed to the new baseline but NOT claimed fully current:
// changes were preserved, so it is marked partial and generatedWith is unchanged.
const prov = JSON.parse(write['packkit.json']);
assert.equal(prov.version, '3.0.0', 'generatedWith unchanged by a partial upgrade');
assert.equal(prov.lastUpgradeAppliedWith, '3.2.0', 'records the version applied');
assert.equal(prov.lastUpgradeCheckedWith, '3.2.0', 'records the version checked');
assert.equal(prov.upgradeStatus, 'partial', 'unresolved changes → partial');
assert.ok(prov.unresolvedChanges > 0, 'counts what was left unresolved');

const merged = JSON.parse(write['package.json']);
assert.equal(merged.scripts.test, 'vitest', 'new script added');
Expand All @@ -114,6 +122,19 @@ test('default apply: adds new, preserves every differing value', () => {
assert.equal(merged.devDependencies.typescript, '5.8.0', 'changed dep version PRESERVED');
});

test('full apply (replace-changed everywhere) marks provenance current', () => {
const { generated, onDisk, plan } = scenario();
const write = buildUpgradeWrite({
generated, onDisk, plan,
policy: { files: 'replace-changed', scripts: 'replace-changed', dependencies: 'replace-changed', packageFields: 'replace-changed' },
});

const prov = JSON.parse(write['packkit.json']);
assert.equal(prov.upgradeStatus, 'current', 'nothing left unresolved → current');
assert.equal(prov.unresolvedChanges, undefined, 'no count when fully current');
assert.equal(prov.lastUpgradeAppliedWith, '3.2.0', 'records the version applied');
});

test('explicit policy replaces changed scripts / deps only when asked', () => {
const { generated, onDisk, plan } = scenario();

Expand Down
20 changes: 20 additions & 0 deletions types/embedded.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,26 @@ export function createProjectFromDefinition(
export function calculateProjectDigest(project: GeneratedProject): string;
export function deriveDeploymentContract(config: ResolvedPackkitConfig): DeploymentContract;

/** Whether a project fully matches the Packkit version it was last upgraded to.
* `partial` means an upgrade applied some changes but left others unresolved
* (preserved user edits / conflicts); `current` means nothing was left. */
export type UpgradeStatus = 'current' | 'partial' | 'conflicted';

/** The upgrade-tracking fields Packkit writes into `packkit.json`. All are
* additive and optional, so pre-existing packkit.json files stay valid. */
export interface UpgradeProvenance {
/** The version the project was originally scaffolded with. An upgrade never
* changes this — it is historical provenance, not the current state. */
version?: string;
/** The Packkit version used to compute the most recent upgrade plan. */
lastUpgradeCheckedWith?: string;
/** The Packkit version whose patch was most recently applied. */
lastUpgradeAppliedWith?: string;
upgradeStatus?: UpgradeStatus;
/** Count of changes an upgrade left unresolved; omitted when fully current. */
unresolvedChanges?: number;
}

export type DependencySection = 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies';

/** Three-way classification of a value that differs from the current template. */
Expand Down