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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,18 @@ Deletion/pruning preview by default and require `--apply` to unlink records. No
automatic history, browser opening, rescanning, deletion, telemetry or Cloud sync.
[The history guide](docs/SCANNER_HISTORY.md) documents storage, bounds, strict parsing,
privacy, original evidence, age/keep-last selection and filesystem/interruption risks.

### Local comparison — unpublished Phase C candidate

On this development branch, compare two explicitly saved local scan IDs without
rescanning or modifying records:

```sh
node dist/index.js history compare BEFORE_UUID AFTER_UUID --directory ./my-history --html ./new-comparison.html
```

Terminal and `--json` comparison are also available. Source movement, incomplete
coverage and Scanner/catalog differences remain explicit uncertainties. Disappearing
findings do not prove a fix; source changes are manual or external. See the
[matching and safety guide](docs/SCANNER_HISTORY.md#local-comparison--unpublished-phase-c-candidate).
Phase C is a local review candidate, not advertised as merged or released.
59 changes: 59 additions & 0 deletions docs/SCANNER_HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,62 @@ Local history is a free manual evidence feature. It adds no continuous monitorin
refreshed provider intelligence, advanced impact analysis, repair, customer-patch
verification, GitHub repair PR, hosted/team sync, account or billing capability.
Cloud remains planned and unavailable. Phase C comparison requires separate work.

## Local comparison — unpublished Phase C candidate

This development branch adds comparison for review; Phase C is not advertised as
merged or publicly available. Compare two explicit saved UUIDs from the same
initialized history directory:

```sh
node dist/index.js history compare BEFORE_UUID AFTER_UUID --directory ./my-history
node dist/index.js history compare BEFORE_UUID AFTER_UUID --directory ./my-history --json
node dist/index.js history compare BEFORE_UUID AFTER_UUID --directory ./my-history --html ./new-comparison.html
```

Both records must pass the existing strict schema-1 validator and belong to the
same project UUID. No legacy raw JSON or HTML import, cross-project override,
implicit latest selection or target lookup is supported. Argument position defines
before/after even if clocks are equal or reversed; warnings preserve that distinction.
The existing private, bounded, identity-checked repository reader is reused. Neither
record is modified, and comparison does not acquire a mutation lock, rescan source,
read today's catalog, execute code, open a browser or access the network.

Independent comparison schema 1 contains original Scanner/catalog metadata,
project/scan IDs, scope, coverage, findings and privacy-filtered saved references.
Integrations match on provider/package. Assessments match on provider/package,
change ID, scope/category and a unique saved client reference corroborated by its
original definition file/line and manifest. Explicit integration-level fallback
matches only when no client is recorded. Multiple candidates are inconclusive;
moved/renamed/withheld client scope is never guessed. Historical record fingerprints
must agree for like-for-like status transitions. Finding references remain evidence,
not persistent source identities or proof of authenticity.

Categories describe newly detected, no longer detected and unchanged integrations;
newly recorded, no longer recorded, changed-status, unchanged-status and inconclusive
assessments. Increased/reduced uncertainty describes transitions into/out of Unknown
only. Unchanged integration identity can still have different declarations or source
references. Evidence comparison retains declaration row associations and sorts block
representations/notes; order changes within a block may conservatively count as an
evidence difference. No overall health score or runtime outcome is computed.

Scanner version/build, catalog revision/fingerprint/provider coverage, effective
budgets and supported coverage are compared separately. Missing metadata, truncation,
partial scope, incompatible context and ambiguous/unmatched assessments are explicit
limitations. Observed statuses remain visible without recalculation even when causal
attribution is inconclusive. Hidden raw API declaration values and other omitted
privacy details cannot be reconstructed: equal displayed context is not proof of
identical runtime inputs. A missing finding never means fixed, resolved or repaired.
Any source changes are manual or external; comparison performs no repair.

Terminal output provides summaries; JSON and collapsed dashboard evidence retain the
full compared privacy-filtered references. HTML uses the Phase A exclusive new-file
writer, escaping and static hashed-CSS CSP; there are no scripts or external assets.
Output is capped at 32 MiB and no truncation silently discards results. Two input
records retain the existing 32 MiB/100,000-node/depth-64 limits each. Matching uses
bounded maps and sorted keys, not pairwise fuzzy matching. Validation/parsing is not
a hard CPU/heap sandbox. The two reads are sequential, not an atomic directory
snapshot; retain immutable records in a stable private directory. Portable filesystem
race limits, privacy-sensitive relative labels and unauthenticated records remain.
Invalid/missing/unsafe input and output errors return 1; valid inconclusive comparisons
return 0 with warnings, not a compatibility verdict. No paid Cloud features are added.
17 changes: 16 additions & 1 deletion scripts/verify-pilot-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,21 @@ registerHooks({ resolve(specifier, context, nextResolve) {
const saved = JSON.parse(run(['history', 'list', '--directory', history, '--json', '--html', resolve(work, 'history-list.html')]));
assert.equal(saved.records.length, 2);
const first = saved.records[0].scanId;
const beforeBytes = await readFile(resolve(history, `${saved.records[1].scanId}.json`));
const afterBytes = await readFile(resolve(history, `${first}.json`));
const compareArgs = ['history', 'compare', saved.records[1].scanId, first, '--directory', history, '--json', '--html', resolve(work, 'comparison.html')];
const comparison = JSON.parse(run(compareArgs));
assert.equal(comparison.schemaVersion, 1);
assert.ok(comparison.assessments.every((item) => item.category === 'unchanged'));
assert.ok(!JSON.stringify(comparison).includes(fixture));
const comparisonHtml = await readFile(resolve(work, 'comparison.html'), 'utf8');
assert.match(comparisonHtml, /Scan Comparison/);
assert.ok(!/<(?:script|img|iframe|form|link)\b/i.test(comparisonHtml));
assert.equal(spawnSync(process.execPath, [...args, cli, ...compareArgs], settings).status, 1);
assert.equal(await readFile(resolve(work, 'comparison.html'), 'utf8'), comparisonHtml);
assert.deepEqual(await readFile(resolve(history, `${saved.records[1].scanId}.json`)), beforeBytes);
assert.deepEqual(await readFile(resolve(history, `${first}.json`)), afterBytes);

const reopened = JSON.parse(run(['history', 'show', first, '--directory', history, '--json', '--html', resolve(work, 'historical.html')]));
assert.ok(!JSON.stringify(reopened).includes(fixture));
assert.ok(!JSON.stringify(reopened).includes('SYNTHETIC_PRIVATE_SENTINEL'));
Expand All @@ -110,6 +125,6 @@ registerHooks({ resolve(specifier, context, nextResolve) {
assert.equal(changes.catalogCoverage.liveMonitoring, false);
const invalid = spawnSync(process.execPath, [...args, cli, 'scan', '../outside', '--json'], settings);
assert.equal(invalid.status, 1);
console.log(JSON.stringify({ archive, sha256: digest, installation: 'passed: empty prefix/cache, offline, scripts disabled', runtime: 'self-contained; parent dependencies blocked', network: 'common Node transports denied; help/terminal/JSON/HTML/catalog/history passed', html: 'privacy/CSP/JSON compatibility/no-overwrite passed', history: 'init/two saves/list/reopen/delete preview/apply/prune preview passed', executable: 'passed', targetUnchanged: true, ignoredEnv: true, traversalRejected: true, files: expected, node: process.version }, null, 2));
console.log(JSON.stringify({ archive, sha256: digest, installation: 'passed: empty prefix/cache, offline, scripts disabled', runtime: 'self-contained; parent dependencies blocked', network: 'common Node transports denied; help/terminal/JSON/HTML/catalog/history passed', html: 'privacy/CSP/JSON compatibility/no-overwrite passed', history: 'init/two saves/list/reopen/compare with immutable inputs and no-overwrite/delete preview/apply/prune preview passed', executable: 'passed', targetUnchanged: true, ignoredEnv: true, traversalRejected: true, files: expected, node: process.version }, null, 2));
} finally { await rm(work, { recursive: true, force: true }); }
assert.equal(root, process.cwd());
20 changes: 16 additions & 4 deletions src/cli/history.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { compareSavedScans } from '../comparison/index.ts';
import { formatComparisonTerminal, formatComparisonJson, formatComparisonHtml } from '../reports/comparison.ts';
import type { CliOutput } from './run.ts';
import { LocalScanHistoryRepository } from '../history/repository.ts';
import { formatHistoryTerminal, formatHistoryHtml, formatSavedTerminal, historySummary } from '../reports/history.ts';
Expand All @@ -11,6 +13,7 @@ history init --directory DIR [--json]
scan TARGET --history-dir DIR --project-id UUID [--json] [--html NEW.html]
history list --directory DIR [--project-id UUID] [--json] [--html NEW.html]
history show SCAN_UUID --directory DIR [--json] [--html NEW.html]
history compare BEFORE_UUID AFTER_UUID --directory DIR [--json] [--html NEW.html]
history delete SCAN_UUID --directory DIR [--apply] [--json]
history prune --directory DIR --project-id UUID [--keep-last N] [--older-than-days N] [--apply] [--json]

Expand All @@ -22,25 +25,34 @@ Records and reports can retain sensitive root-relative labels; inspect privately

export async function runHistory(args: string[], output: CliOutput): Promise<number> {
const command = args[1];
if (!command || !['init', 'list', 'show', 'delete', 'prune'].includes(command)) throw new Error('Unknown history command. Use patchwrk history --help.');
const values = new Map<string, string>(); let scanId: string | undefined; let json = false; let apply = false;
if (!command || !['init', 'list', 'show', 'delete', 'prune', 'compare'].includes(command)) throw new Error('Unknown history command. Use patchwrk history --help.');
const values = new Map<string, string>(); let scanId: string | undefined; let afterId: string | undefined; let json = false; let apply = false;
const paths = new Set(['--directory', '--project-id', '--html', '--keep-last', '--older-than-days']);
for (let index = 2; index < args.length; index++) {
const arg = args[index]!;
if (arg === '--json' && !json) json = true;
else if (arg === '--apply' && !apply) apply = true;
else if (paths.has(arg) && !values.has(arg)) {
const value = args[++index]; if (!value || value.startsWith('-')) throw new Error('Missing history option value.'); values.set(arg, value);
} else if (!arg.startsWith('-') && scanId === undefined && ['show', 'delete'].includes(command)) scanId = uuid(arg);
} else if (!arg.startsWith('-') && scanId === undefined && ['show', 'delete', 'compare'].includes(command)) scanId = uuid(arg);
else if (!arg.startsWith('-') && command === 'compare' && afterId === undefined) afterId = uuid(arg);
else throw new Error('Unknown or duplicate history argument.');
}
const allowed: Record<string, string[]> = { init: ['--directory'], list: ['--directory', '--project-id', '--html'], show: ['--directory', '--html'], delete: ['--directory'], prune: ['--directory', '--project-id', '--keep-last', '--older-than-days'] };
const allowed: Record<string, string[]> = { compare: ['--directory', '--html'], init: ['--directory'], list: ['--directory', '--project-id', '--html'], show: ['--directory', '--html'], delete: ['--directory'], prune: ['--directory', '--project-id', '--keep-last', '--older-than-days'] };
if ([...values.keys()].some((key) => !allowed[command]!.includes(key)) || (apply && !['delete', 'prune'].includes(command))) throw new Error('Option not supported for this history command.');
const dir = values.get('--directory'); if (!dir) throw new Error('History requires an explicit --directory.');
if (command === 'init') {
const project = await LocalScanHistoryRepository.init(dir); output.stdout(json ? JSON.stringify(project, null, 2) : `History initialized. Project UUID: ${project.projectId}`); return 0;
}
if (command === 'compare' && (!scanId || !afterId)) throw new Error('History compare requires explicit before and after scan UUIDs.');
const repo = await LocalScanHistoryRepository.open(dir, values.get('--project-id'));
if (command === 'compare') {
const comparison = compareSavedScans(await repo.get(scanId!), await repo.get(afterId!));
const text = json ? formatComparisonJson(comparison) : formatComparisonTerminal(comparison);
const html = values.get('--html');
if (html) await writeNewArtifact(html, formatComparisonHtml(comparison));
output.stdout(text); return 0;
}
if (command === 'list') {
const listing = await repo.list(); const html = values.get('--html');
if (html) await writeNewArtifact(html, formatHistoryHtml(listing));
Expand Down
Loading
Loading