@weftlet/toolkit 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +15 -0
  2. package/dist/application/application.js +7 -4
  3. package/dist/application/cache-adoption.d.ts +43 -0
  4. package/dist/application/cache-adoption.js +129 -0
  5. package/dist/application/extraction-identity.d.ts +6 -0
  6. package/dist/application/extraction-identity.js +15 -0
  7. package/dist/application/hydration-failure.d.ts +41 -0
  8. package/dist/application/hydration-failure.js +43 -0
  9. package/dist/application/hydration-plan.d.ts +4 -7
  10. package/dist/application/hydration-plan.js +7 -15
  11. package/dist/application/hydrator.d.ts +3 -5
  12. package/dist/application/hydrator.js +23 -48
  13. package/dist/application/overlay.js +10 -3
  14. package/dist/application/saved-evidence.d.ts +6 -3
  15. package/dist/application/saved-evidence.js +7 -1
  16. package/dist/application/semantic-artifact-store.d.ts +4 -0
  17. package/dist/application/semantic-artifact-store.js +7 -0
  18. package/dist/application/semantic-run.d.ts +29 -0
  19. package/dist/application/semantic-run.js +111 -0
  20. package/dist/application/semantic-scope.d.ts +13 -0
  21. package/dist/application/semantic-scope.js +12 -0
  22. package/dist/application/snapshot-checks.d.ts +2 -1
  23. package/dist/application/snapshot-checks.js +8 -1
  24. package/dist/configuration/cache.d.ts +6 -0
  25. package/dist/configuration/cache.js +18 -0
  26. package/dist/extraction/document.js +1 -1
  27. package/dist/extraction/providers.js +10 -3
  28. package/dist/extraction/repository-nodes.d.ts +2 -3
  29. package/dist/extraction/repository-nodes.js +5 -11
  30. package/dist/extraction/routing.d.ts +1 -1
  31. package/dist/extraction/routing.js +3 -6
  32. package/dist/extraction/semantic-cache.d.ts +1 -0
  33. package/dist/extraction/semantic-cache.js +9 -2
  34. package/dist/extraction/semantic-diagnostics.d.ts +9 -0
  35. package/dist/extraction/semantic-diagnostics.js +15 -0
  36. package/dist/extraction/semantic.d.ts +5 -1
  37. package/dist/extraction/semantic.js +39 -6
  38. package/dist/extraction/static.d.ts +13 -5
  39. package/dist/extraction/static.js +8 -15
  40. package/dist/governance/governance.js +11 -7
  41. package/dist/graph/query.js +2 -0
  42. package/dist/index.d.ts +3 -0
  43. package/dist/index.js +3 -0
  44. package/dist/interfaces/cli.js +44 -21
  45. package/dist/storage/storage.d.ts +7 -1
  46. package/dist/storage/storage.js +15 -3
  47. package/dist/visualization/visualization.js +1 -0
  48. package/docs/ADR-016-hydration-preview.md +1 -1
  49. package/docs/ADR-017-shacl-validation-union.md +1 -1
  50. package/docs/ADR-018-operational-semantic-cache.md +100 -0
  51. package/docs/ADR-019-semantic-failure-diagnostics.md +82 -0
  52. package/docs/ADR-020-canonical-physical-file-producers.md +97 -0
  53. package/docs/PROVIDERS.md +51 -1
  54. package/docs/RELEASE-1.0-PLAN.md +70 -21
  55. package/docs/RELEASING.md +4 -2
  56. package/docs/ROADMAP.md +31 -12
  57. package/docs/VALIDATION.md +212 -4
  58. package/docs/extraction-manifest.json +42 -21
  59. package/package.json +2 -2
@@ -11,6 +11,7 @@ import { sha256, stableJson } from "../infrastructure/util.js";
11
11
  import { canonicalizeRepositoryDocumentNode } from './repository-nodes.js';
12
12
  import { semanticArtifactKey, semanticArtifactOutputDigest, semanticProviderIdentity } from './semantic-identity.js';
13
13
  import { inspectSemanticCache } from './semantic-cache.js';
14
+ import { SemanticPolicyError } from './semantic-diagnostics.js';
14
15
  import { assertSemanticCitation, numberedSemanticSource, semanticCitationSchema, } from "./semantic-citations.js";
15
16
  function canonicalizeRepositoryDocuments(output) {
16
17
  const ids = new Map();
@@ -334,14 +335,32 @@ export class SemanticHydrator {
334
335
  this.extractor = extractor;
335
336
  }
336
337
  async hydrate(source, context, forceRevision, options = {}) {
337
- options.signal?.throwIfAborted();
338
+ const checkpoint = () => {
339
+ if (options.signal?.aborted)
340
+ throw new SemanticProviderError('cancelled', 'Semantic extraction cancelled');
341
+ };
342
+ checkpoint();
343
+ options.onPhase?.('semantic-cache-read');
338
344
  const key = semanticArtifactKey(this.config, source, this.extractor.identity, forceRevision);
339
345
  const cached = await inspectSemanticCache(this.store, this.config, source, this.extractor.identity, forceRevision);
340
- if (cached.status === 'hit')
341
- return { artifact: cached.artifact, reused: true };
346
+ if (cached.status === 'hit') {
347
+ // Planning remains read-only. Execution can copy an inspected legacy hit
348
+ // atomically without rewriting its original artifact or extraction identity.
349
+ checkpoint();
350
+ if (cached.origin === 'legacy')
351
+ options.onPhase?.('semantic-cache-write');
352
+ const artifact = cached.origin === 'legacy'
353
+ ? await this.promoteLegacy(source, cached.artifact, forceRevision) : cached.artifact;
354
+ return { artifact, reused: true };
355
+ }
342
356
  if (cached.status !== 'miss')
343
357
  throw new Error(cached.reason);
344
- const extraction = await this.extractor.extract(source, context, options);
358
+ checkpoint();
359
+ options.onPhase?.('semantic-extract');
360
+ // Phase observation belongs to hydration, not the provider extension contract.
361
+ const extraction = await this.extractor.extract(source, context, { signal: options.signal, timeoutMs: options.timeoutMs });
362
+ checkpoint();
363
+ options.onPhase?.('semantic-validate');
345
364
  assertSemanticOutput(extraction.parsed, source);
346
365
  if (typeof extraction.rawModelOutput !== 'string' || Buffer.byteLength(extraction.rawModelOutput) > 2 * 1024 * 1024) {
347
366
  throw new SemanticProviderError('invalid-output', 'Invalid or oversized raw semantic output');
@@ -351,7 +370,7 @@ export class SemanticHydrator {
351
370
  const facts = output.assertions.map((assertion, index) => {
352
371
  assertSemanticCitation(assertion.citation, source.path, lineCount, `assertion[${index}]`);
353
372
  if (assertion.confidence < this.config.minimumConfidence) {
354
- throw new Error(`Low-confidence semantic assertion for ${source.path}`);
373
+ throw new SemanticPolicyError('low-confidence', `Low-confidence semantic assertion for ${source.path}`);
355
374
  }
356
375
  const provenance = {
357
376
  path: source.path,
@@ -398,7 +417,7 @@ export class SemanticHydrator {
398
417
  });
399
418
  const unresolvedMappings = output.unresolvedMappings ?? [];
400
419
  if (this.config.failOnUnresolved && (unresolvedMappings.length > 0 || diagnostics.length > 0)) {
401
- throw new Error(`Unresolved semantic mappings for ${source.path}: ${[...unresolvedMappings, ...diagnostics.map((item) => item.reason)].join('; ')}`);
420
+ throw new SemanticPolicyError('unresolved', `Unresolved semantic mappings for ${source.path}: ${[...unresolvedMappings, ...diagnostics.map((item) => item.reason)].join('; ')}`);
402
421
  }
403
422
  const compiledOml = renderOml(key, output.assertions);
404
423
  const outputDigest = semanticArtifactOutputDigest({ facts, diagnostics, oml: compiledOml, rawModelOutput: extraction.rawModelOutput });
@@ -425,7 +444,21 @@ export class SemanticHydrator {
425
444
  rawModelOutput: extraction.rawModelOutput,
426
445
  outputDigest,
427
446
  };
447
+ checkpoint();
448
+ options.onPhase?.('semantic-cache-write');
428
449
  await this.store.write(artifact);
429
450
  return { artifact, reused: false };
430
451
  }
452
+ async promoteLegacy(source, artifact, forceRevision) {
453
+ await this.store.write(artifact);
454
+ // A concurrent winner must satisfy the full contract, not merely expose the
455
+ // same outputDigest field. Never publish using an invalid retained artifact.
456
+ const retained = await inspectSemanticCache(this.store, this.config, source, this.extractor.identity, forceRevision);
457
+ if (retained.status !== 'hit')
458
+ throw new Error(retained.reason);
459
+ if (retained.origin !== 'primary' || retained.artifact.outputDigest !== artifact.outputDigest) {
460
+ throw new Error('Concurrent semantic artifact changed during legacy promotion; inspect cache integrity before retrying.');
461
+ }
462
+ return retained.artifact;
463
+ }
431
464
  }
@@ -1,5 +1,5 @@
1
1
  import type { ContextGraphConfig } from "../configuration/config.js";
2
- import type { ExtractionContext, FactExtractor, GraphFact } from "@weftlet/core";
2
+ import { type ExtractionContext, type FactExtractor, type GraphFact } from "@weftlet/core";
3
3
  import { GitRepository } from "../repository/git.js";
4
4
  export interface ScipIndexResult {
5
5
  readonly path: string;
@@ -17,12 +17,16 @@ export declare class ScipTypeScriptIndexer implements StaticIndexer {
17
17
  index(context: ExtractionContext): Promise<ScipIndexResult>;
18
18
  }
19
19
  export declare function normalizeScipCheckout(bytes: Uint8Array, checkoutRoot: string): Uint8Array;
20
+ export declare const scipSymbolExtractorIdentity: {
21
+ readonly id: "scip-symbol-extractor";
22
+ readonly version: "1.1.0";
23
+ };
20
24
  export declare class ScipSymbolExtractor implements FactExtractor {
21
25
  private readonly repository;
22
26
  private readonly index;
23
27
  readonly identity: {
24
- id: string;
25
- version: string;
28
+ readonly id: "scip-symbol-extractor";
29
+ readonly version: "1.1.0";
26
30
  };
27
31
  constructor(repository: GitRepository, index: ScipIndexResult);
28
32
  extract(context: ExtractionContext): Promise<readonly GraphFact[]>;
@@ -42,14 +46,18 @@ export declare class RepositoryFileExtractor implements FactExtractor {
42
46
  constructor(repository: GitRepository, roots: readonly string[], scipDigest: string);
43
47
  extract(context: ExtractionContext): Promise<readonly GraphFact[]>;
44
48
  }
49
+ export declare const repositoryContractExtractorIdentity: {
50
+ readonly id: "repository-contract-extractor";
51
+ readonly version: "1.2.0";
52
+ };
45
53
  export declare class RepositoryContractExtractor implements FactExtractor {
46
54
  private readonly repository;
47
55
  private readonly patterns;
48
56
  private readonly consumerRoots;
49
57
  private readonly aliases;
50
58
  readonly identity: {
51
- id: string;
52
- version: string;
59
+ readonly id: "repository-contract-extractor";
60
+ readonly version: "1.2.0";
53
61
  };
54
62
  constructor(repository: GitRepository, patterns: readonly string[], consumerRoots: readonly string[], aliases?: Readonly<Record<string, readonly string[]>>);
55
63
  extract(context: ExtractionContext): Promise<readonly GraphFact[]>;
@@ -5,6 +5,7 @@ import { copyFile, mkdir, readFile, rename, stat, writeFile } from 'node:fs/prom
5
5
  import { createRequire } from 'node:module';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { pathToFileURL } from 'node:url';
8
+ import { repositoryFileNode } from "@weftlet/core";
8
9
  import { GitRepository } from "../repository/git.js";
9
10
  import { assertScipSourceProject, assertSourceProjectInvocation } from '../configuration/scip-project.js';
10
11
  import { prepareScipSourceProject } from './scip-project.js';
@@ -90,10 +91,11 @@ export function normalizeScipCheckout(bytes, checkoutRoot) {
90
91
  }
91
92
  return index.serializeBinary();
92
93
  }
94
+ export const scipSymbolExtractorIdentity = { id: 'scip-symbol-extractor', version: '1.1.0' };
93
95
  export class ScipSymbolExtractor {
94
96
  repository;
95
97
  index;
96
- identity = { id: 'scip-symbol-extractor', version: '1.0.0' };
98
+ identity = scipSymbolExtractorIdentity;
97
99
  constructor(repository, index) {
98
100
  this.repository = repository;
99
101
  this.index = index;
@@ -105,11 +107,7 @@ export class ScipSymbolExtractor {
105
107
  const path = document.relative_path.replace(/\\/g, '/');
106
108
  const content = await this.repository.readFileAt(context.commitSha, path);
107
109
  const digest = sha256(content);
108
- const component = {
109
- id: `component:file:${path}`,
110
- kind: (/\.(spec|test)\.[^.]+$/i.test(path) ? 'Evidence' : 'Component'),
111
- label: path,
112
- };
110
+ const component = repositoryFileNode(path);
113
111
  for (const occurrence of document.occurrences) {
114
112
  if (!occurrence.symbol || occurrence.symbol.startsWith('local '))
115
113
  continue;
@@ -195,9 +193,7 @@ export class RepositoryFileExtractor {
195
193
  const content = await this.repository.readFileAt(context.commitSha, path);
196
194
  const digest = sha256(content);
197
195
  const component = {
198
- id: `component:file:${path}`,
199
- kind: (/\.(spec|test)\.[^.]+$/i.test(path) ? 'Evidence' : 'Component'),
200
- label: path,
196
+ ...repositoryFileNode(path),
201
197
  attributes: { scipIndexDigest: this.scipDigest },
202
198
  };
203
199
  const provenance = {
@@ -239,12 +235,13 @@ export class RepositoryFileExtractor {
239
235
  return facts;
240
236
  }
241
237
  }
238
+ export const repositoryContractExtractorIdentity = { id: 'repository-contract-extractor', version: '1.2.0' };
242
239
  export class RepositoryContractExtractor {
243
240
  repository;
244
241
  patterns;
245
242
  consumerRoots;
246
243
  aliases;
247
- identity = { id: 'repository-contract-extractor', version: '1.1.0' };
244
+ identity = repositoryContractExtractorIdentity;
248
245
  constructor(repository, patterns, consumerRoots, aliases = {}) {
249
246
  this.repository = repository;
250
247
  this.patterns = patterns;
@@ -306,11 +303,7 @@ export class RepositoryContractExtractor {
306
303
  digest: sha256(consumerContent),
307
304
  commitSha: context.commitSha,
308
305
  };
309
- const consumer = {
310
- id: `component:file:${match.path}`,
311
- kind: 'Component',
312
- label: match.path,
313
- };
306
+ const consumer = repositoryFileNode(match.path);
314
307
  facts.push({
315
308
  id: sha256(stableJson({ consumer, contract, consumerProvenance })),
316
309
  subject: consumer,
@@ -2,10 +2,12 @@ import { readFile, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { isAbsolute, join, relative, resolve } from 'node:path';
4
4
  import { createRequire } from 'node:module';
5
+ import { repositoryFileNode } from '@weftlet/core';
5
6
  import { GitRepository } from "../repository/git.js";
6
7
  import { RdfEvidenceGraph, CG, nodeIri } from "../graph/rdf.js";
7
8
  import { sha256, stableJson } from "../infrastructure/util.js";
8
9
  import { runProcess } from "../infrastructure/process.js";
10
+ const architectureAnalyzerVersion = '3';
9
11
  export async function analyzeArchitecture(root, policy, policyPath = 'architecture.policy.json') {
10
12
  const findings = [];
11
13
  const add = (category, ruleId, from, to, message) => {
@@ -86,11 +88,11 @@ export async function analyzeArchitecture(root, policy, policyPath = 'architectu
86
88
  const lines = content.split('\n');
87
89
  facts.push({
88
90
  id: sha256(stableJson({ from: module.source, to: dependency.resolved, digest: inputs[module.source] })),
89
- subject: { id: `component:file:${module.source}`, kind: 'Component', label: module.source,
91
+ subject: { ...repositoryFileNode(module.source),
90
92
  attributes: membership[0] ? { domain: membership[0].domain, layer: membership[0].layer,
91
93
  module: membership[0].id, adapter: Boolean(membership[0].adapter) } : {} },
92
94
  predicate: 'consumes',
93
- object: { id: `component:file:${dependency.resolved}`, kind: 'Component', label: dependency.resolved },
95
+ object: repositoryFileNode(dependency.resolved),
94
96
  provenance: { path: module.source, startLine: 1, endLine: lines.length, digest: inputs[module.source], commitSha },
95
97
  extractor: { id: 'dependency-cruiser', version: '18.7.0' }, confidence: 1,
96
98
  authority: dirtyPaths.has(module.source) ? 'draft' : 'implemented', snapshotId: 'pending',
@@ -128,7 +130,7 @@ export async function analyzeArchitecture(root, policy, policyPath = 'architectu
128
130
  attributes: { domain: module.domain, layer: module.layer, adapter: Boolean(module.adapter) } };
129
131
  addPolicyFact(node, 'governedBy', { id: `layer:${module.layer}`, kind: 'Scope', label: module.layer });
130
132
  for (const path of Object.keys(inputs).filter((path) => new RegExp(module.path).test(path))) {
131
- addPolicyFact(node, 'contains', { id: `component:file:${path}`, kind: 'Component', label: path });
133
+ addPolicyFact(node, 'contains', repositoryFileNode(path));
132
134
  }
133
135
  }
134
136
  for (const contract of policy.contracts ?? []) {
@@ -137,14 +139,16 @@ export async function analyzeArchitecture(root, policy, policyPath = 'architectu
137
139
  if (!Object.keys(inputs).length)
138
140
  add('extraction-gap', 'source-coverage', '', '', 'No source files were analyzed');
139
141
  const contentDigest = sha256(stableJson({ inputs, policyDigest, commitSha, dirtyPaths: [...dirtyPaths].sort(),
140
- tools: { dependencyCruiser: '18.7.0', eslint: '10.13.0', jscpd: '5.4.1', analyzer: '2' } }));
142
+ tools: { dependencyCruiser: '18.7.0', eslint: '10.13.0', jscpd: '5.4.1', analyzer: architectureAnalyzerVersion } }));
141
143
  const snapshotId = `architecture-${contentDigest}`;
142
144
  const snapshot = {
143
145
  manifest: {
144
146
  schemaVersion: 1, repositoryId: 'architecture-analysis', ref: 'HEAD', commitSha, snapshotId,
145
147
  configurationDigest: policyDigest, scope: policy.roots, contentDigest,
146
148
  ...(dirtyPaths.size ? { baseSha: commitSha, overlayDigest: contentDigest } : {}),
147
- ontologyVersion: 'architecture-v1', extractorVersions: { 'dependency-cruiser': '18.7.0' },
149
+ ontologyVersion: 'architecture-v1', extractorVersions: {
150
+ 'dependency-cruiser': '18.7.0', 'architecture-analyzer': architectureAnalyzerVersion,
151
+ },
148
152
  semanticModel: 'none', semanticPromptVersion: 'none', generatedAt: new Date().toISOString(),
149
153
  semanticArtifactKeys: [], scipIndexDigest: '', rdfDigest: '',
150
154
  },
@@ -158,8 +162,8 @@ export async function analyzeArchitecture(root, policy, policyPath = 'architectu
158
162
  if (result.truncated)
159
163
  add('extraction-gap', rule.id, '', '', 'Transitive dependency query truncated');
160
164
  for (const row of result.rows) {
161
- const from = modules.find((m) => nodeIri(`component:file:${m.source}`) === row.from)?.source;
162
- const to = modules.find((m) => nodeIri(`component:file:${m.source}`) === row.to)?.source;
165
+ const from = modules.find((m) => nodeIri(repositoryFileNode(m.source).id) === row.from)?.source;
166
+ const to = modules.find((m) => nodeIri(repositoryFileNode(m.source).id) === row.to)?.source;
163
167
  if (from && to && new RegExp(rule.from).test(from) && new RegExp(rule.to).test(to)) {
164
168
  add(rule.approved ? 'rule-violation' : 'advisory', rule.id, from, to, 'Transitive dependency inferred by SPARQL property path from source import evidence');
165
169
  }
@@ -113,6 +113,7 @@ export class MarkdownContextPackRenderer {
113
113
  ] : []),
114
114
  ...(pack.evidenceState ? [
115
115
  `Stale relative to checkout: ${pack.evidenceState.stale}; configuration matches: ${pack.evidenceState.configurationMatches}`,
116
+ `Extraction compatibility: ${pack.evidenceState.extractionCompatible === undefined ? 'not assessed' : pack.evidenceState.extractionCompatible ? 'current' : `refresh required (${pack.evidenceState.incompatibleExtractors?.join(', ') || 'unknown versions'})`}`,
116
117
  `Excluded working changes: ${pack.evidenceState.excludedWorkingChanges.join(', ') || '(none)'}`,
117
118
  ...(pack.evidenceState.overlayFreshness ? [
118
119
  `Overlay freshness: ${pack.evidenceState.overlayFreshness}`,
@@ -327,6 +328,7 @@ export class SnapshotImpactQueryService {
327
328
  baseSha: snapshot.manifest.baseSha,
328
329
  overlayDigest: snapshot.manifest.overlayDigest,
329
330
  overlayCapture: snapshot.manifest.overlayCapture,
331
+ extractorVersions: snapshot.manifest.extractorVersions,
330
332
  },
331
333
  capabilities,
332
334
  facts,
package/dist/index.d.ts CHANGED
@@ -10,3 +10,6 @@ export * from "./application/overlay.js";
10
10
  export * from "./graph/rdf.js";
11
11
  export * from "./extraction/providers.js";
12
12
  export * from './application/hydration-plan.js';
13
+ export * from './application/cache-adoption.js';
14
+ export * from './application/hydration-failure.js';
15
+ export * from './extraction/semantic-diagnostics.js';
package/dist/index.js CHANGED
@@ -11,3 +11,6 @@ export * from "./application/overlay.js";
11
11
  export * from "./graph/rdf.js";
12
12
  export * from "./extraction/providers.js";
13
13
  export * from './application/hydration-plan.js';
14
+ export * from './application/cache-adoption.js';
15
+ export * from './application/hydration-failure.js';
16
+ export * from './extraction/semantic-diagnostics.js';
@@ -16,6 +16,8 @@ import { diagnoseSetup } from "./doctor.js";
16
16
  import { runInit } from "./init.js";
17
17
  import { loadConfig } from "../configuration/config.js";
18
18
  import { planHydration } from '../application/hydration-plan.js';
19
+ import { adoptSemanticCache } from '../application/cache-adoption.js';
20
+ import { SemanticHydrationError } from '../application/hydration-failure.js';
19
21
  function valueAfter(args, name) {
20
22
  const index = args.indexOf(name);
21
23
  return index >= 0 ? args[index + 1] : undefined;
@@ -59,7 +61,7 @@ function formatHydrationPlan(plan) {
59
61
  `Source/cache/budget checks: ${readiness}`,
60
62
  ];
61
63
  for (const document of plan.documents) {
62
- lines.push(`- ${JSON.stringify(document.path)}: ${document.bytes} bytes, ${document.lines} lines; ${document.role}; source ${document.sourceAuthority}, extraction ${document.extractionReview}; cache ${document.cache.status}; selected by ${document.selectedBy.map((selector) => JSON.stringify(selector)).join(', ')}`);
64
+ lines.push(`- ${JSON.stringify(document.path)} [${document.sourceId}]: ${document.bytes} bytes, ${document.lines} lines; ${document.role}; source ${document.sourceAuthority}, extraction ${document.extractionReview}; cache ${document.cache.status}; selected by ${document.selectedBy.map((selector) => JSON.stringify(selector)).join(', ')}`);
63
65
  }
64
66
  for (const selection of plan.selections.filter((item) => item.status !== 'matched')) {
65
67
  lines.push(`Selection ${JSON.stringify(selection.selector)}: ${selection.status}`);
@@ -80,7 +82,7 @@ function formatHydrationPlan(plan) {
80
82
  async function main() {
81
83
  const args = process.argv.slice(2);
82
84
  if (args.includes('--help') || !args.length) {
83
- process.stdout.write('weftlet init|config show|config validate|doctor|hydrate|overlay|impact scan|sparql|validate|govern|visualize|diagnostics show|invariant check|clean-compare|ontology compile\nUse --config <file>; queries accept --snapshot <id>. impact scan accepts --changed-file, --capability, --max-facts, --max-depth, --continuation.\nhydrate --plan [--json] [--ref <commit>] [--force-semantic] previews scope, cache and budgets without model calls or graph writes. Preview readiness is advisory and does not add a hydration gate.\ninit [--yes] [--directory <project>] [--config <file>] [--repository-id <id>] [--code-root <path>] [--semantic-source <path>] [--evidence-root <path>] [--model <name>] [--no-gitignore]\nSource flags may be repeated. init never overwrites an existing config. See README.md for examples.\n');
85
+ process.stdout.write('weftlet init|config show|config validate|doctor|hydrate|cache adopt|overlay|impact scan|sparql|validate|govern|visualize|diagnostics show|invariant check|clean-compare|ontology compile\nUse --config <file>; queries accept --snapshot <id>. impact scan accepts --changed-file, --capability, --max-facts, --max-depth, --continuation.\nhydrate --plan [--json] [--ref <commit>] [--force-semantic] previews scope, cache and budgets without model calls or graph writes. Preview readiness is advisory and does not add a hydration gate.\nhydrate --json --quiet emits structured semantic failures on stderr with no progress lines; failure stdout stays empty. hydrate --plan maps opaque source IDs to local document paths.\ncache adopt --from-config <previous.json> [--config <current.json>] [--ref <commit>] [--dry-run] returns JSON and copies only validated compatible artifacts, without model calls or snapshot writes. --dry-run performs no cache writes. Agent configurations accept the same --owner-doc, --task and --capability selection as hydrate.\ninit [--yes] [--directory <project>] [--config <file>] [--repository-id <id>] [--code-root <path>] [--semantic-source <path>] [--evidence-root <path>] [--model <name>] [--no-gitignore]\nSource flags may be repeated. init never overwrites an existing config. See README.md for examples.\n');
84
86
  return;
85
87
  }
86
88
  if (args[0] === 'init') {
@@ -113,6 +115,20 @@ async function main() {
113
115
  return;
114
116
  }
115
117
  const configPath = valueAfter(args, '--config') ?? defaultConfigPath();
118
+ if (args[0] === 'cache') {
119
+ const fromConfigPath = valueAfter(args, '--from-config');
120
+ if (args[1] !== 'adopt' || !fromConfigPath || fromConfigPath.startsWith('--')) {
121
+ throw new Error('Usage: weftlet cache adopt --from-config <previous.json> [--config <current.json>] [--ref <commit>] [--dry-run]');
122
+ }
123
+ const result = await adoptSemanticCache(configPath, {
124
+ fromConfigPath, ref: valueAfter(args, '--ref') ?? 'HEAD', dryRun: args.includes('--dry-run'),
125
+ request: impactRequestFromArgs(args),
126
+ });
127
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
128
+ if (!result.ready)
129
+ process.exitCode = 1;
130
+ return;
131
+ }
116
132
  if (args[0] === 'doctor') {
117
133
  process.stdout.write(`${JSON.stringify(await diagnoseSetup(configPath), null, 2)}\n`);
118
134
  return;
@@ -163,24 +179,29 @@ async function main() {
163
179
  const controller = new AbortController();
164
180
  const cancel = () => controller.abort();
165
181
  process.once('SIGINT', cancel);
166
- const ref = valueAfter(args, '--ref') ?? 'HEAD';
167
- const scope = await application.scopeResolver?.resolve(impactRequestFromArgs(args), ref);
168
- const result = await application.hydrator.hydrate({
169
- ref: scope?.commitSha ?? ref,
170
- headName: scope?.headName ?? application.headName,
171
- semanticSources: scope?.semanticSources,
172
- forceSemantic: args.includes('--force-semantic'),
173
- signal: controller.signal,
174
- onProgress: args.includes('--quiet') ? undefined : (event) => process.stderr.write(`[context-graph] ${event.phase}${event.total === undefined ? '' : ` ${event.completed}/${event.total}`}\n`),
175
- });
176
- process.removeListener('SIGINT', cancel);
177
- process.stdout.write(`${JSON.stringify({
178
- snapshotId: result.snapshot.manifest.snapshotId,
179
- commitSha: result.snapshot.manifest.commitSha,
180
- contentDigest: result.snapshot.manifest.contentDigest,
181
- semanticExtracted: result.semanticExtracted,
182
- semanticReused: result.semanticReused,
183
- }, null, 2)}\n`);
182
+ try {
183
+ const ref = valueAfter(args, '--ref') ?? 'HEAD';
184
+ const scope = await application.scopeResolver?.resolve(impactRequestFromArgs(args), ref);
185
+ const result = await application.hydrator.hydrate({
186
+ ref: scope?.commitSha ?? ref,
187
+ headName: scope?.headName ?? application.headName,
188
+ semanticSources: scope?.semanticSources,
189
+ forceSemantic: args.includes('--force-semantic'),
190
+ signal: controller.signal,
191
+ onProgress: args.includes('--quiet') ? undefined : (event) => process.stderr.write(`[context-graph] ${event.phase}${event.total === undefined ? '' : ` ${event.completed}/${event.total}`}` +
192
+ `${event.sourceId === undefined ? '' : ` ${event.sourceId}`}${event.elapsedMs === undefined ? '' : ` ${event.elapsedMs} ms`}\n`),
193
+ });
194
+ process.stdout.write(`${JSON.stringify({
195
+ snapshotId: result.snapshot.manifest.snapshotId,
196
+ commitSha: result.snapshot.manifest.commitSha,
197
+ contentDigest: result.snapshot.manifest.contentDigest,
198
+ semanticExtracted: result.semanticExtracted,
199
+ semanticReused: result.semanticReused,
200
+ }, null, 2)}\n`);
201
+ }
202
+ finally {
203
+ process.removeListener('SIGINT', cancel);
204
+ }
184
205
  return;
185
206
  }
186
207
  if (command === 'clean-compare') {
@@ -236,6 +257,8 @@ async function main() {
236
257
  throw new Error('Usage: weftlet hydrate|clean-compare|impact scan|diagnostics show --snapshot <id>|invariant check|ontology compile|visualize --output <file.html|file.dot|file.svg>');
237
258
  }
238
259
  main().catch((error) => {
239
- process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
260
+ process.stderr.write(`${error instanceof SemanticHydrationError
261
+ ? process.argv.includes('--json') ? JSON.stringify(error.report, null, 2) : error.message
262
+ : error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
240
263
  process.exitCode = 1;
241
264
  });
@@ -9,7 +9,13 @@ export declare class FileSnapshotStore implements SnapshotStore {
9
9
  }
10
10
  export declare class SemanticArtifactStore {
11
11
  private readonly root;
12
- constructor(root: string);
12
+ private readonly legacyRoots;
13
+ constructor(root: string, legacyRoots?: readonly string[]);
13
14
  read(key: string): Promise<SemanticArtifact | undefined>;
15
+ /** Only explicitly supplied roots are consulted; never enumerate other consumers' profiles. */
16
+ readEntry(key: string): Promise<{
17
+ artifact: SemanticArtifact;
18
+ origin: 'primary' | 'legacy';
19
+ } | undefined>;
14
20
  write(artifact: SemanticArtifact): Promise<void>;
15
21
  }
@@ -87,11 +87,23 @@ export class FileSnapshotStore {
87
87
  }
88
88
  export class SemanticArtifactStore {
89
89
  root;
90
- constructor(root) {
90
+ legacyRoots;
91
+ constructor(root, legacyRoots = []) {
91
92
  this.root = root;
93
+ this.legacyRoots = legacyRoots;
94
+ }
95
+ async read(key) {
96
+ return (await this.readEntry(key))?.artifact;
92
97
  }
93
- read(key) {
94
- return readJson(join(this.root, 'artifacts', 'semantic', `${safeKey(key)}.json`));
98
+ /** Only explicitly supplied roots are consulted; never enumerate other consumers' profiles. */
99
+ async readEntry(key) {
100
+ const filename = `${safeKey(key)}.json`;
101
+ for (const [index, root] of [this.root, ...this.legacyRoots].entries()) {
102
+ const artifact = await readJson(join(root, 'artifacts', 'semantic', filename));
103
+ if (artifact !== undefined)
104
+ return { artifact, origin: index === 0 ? 'primary' : 'legacy' };
105
+ }
106
+ return undefined;
95
107
  }
96
108
  async write(artifact) {
97
109
  const path = join(this.root, 'artifacts', 'semantic', `${safeKey(artifact.key)}.json`);
@@ -394,6 +394,7 @@ export function renderContextPackHtml(pack, sourceUrlTemplate) {
394
394
  <strong>${pack.snapshot.overlayDigest ? 'DRAFT overlay' : pack.evidenceState?.stale ? 'HISTORICAL evidence' : pack.evidenceState ? 'Saved evidence at current commit' : 'Saved evidence — current checkout not assessed'}</strong>
395
395
  <p>Configuration: ${escapeXml(pack.snapshot.configurationDigest ?? 'unknown')}; ${pack.evidenceState ? (pack.evidenceState.configurationMatches ? 'matches current configuration' : 'configuration mismatch') : 'configuration match unknown'}.
396
396
  Scope: ${escapeXml(pack.snapshot.scope?.join(', ') ?? 'unknown')}.
397
+ Extraction compatibility: ${pack.evidenceState?.extractionCompatible === undefined ? 'not assessed' : pack.evidenceState.extractionCompatible ? 'current' : `refresh required (${escapeXml(pack.evidenceState.incompatibleExtractors?.join(', ') ?? 'unknown versions')})`}.
397
398
  Excluded working paths: ${pack.evidenceState?.excludedWorkingChanges.length ?? 'unknown'}.
398
399
  ${pack.snapshot.overlayDigest ? `Overlay freshness: ${escapeXml(pack.evidenceState?.overlayFreshness ?? 'unknown')}.` : ''}
399
400
  This view does not validate the current diff.</p>
@@ -1,6 +1,6 @@
1
1
  # ADR-016: Read-only hydration planning
2
2
 
3
- Status: accepted and implemented for the 0.3.0 release plan; absent from 0.2.1.
3
+ Status: accepted and implemented in 0.3.0; absent from 0.2.1.
4
4
  See [validation](VALIDATION.md) for recorded publication evidence.
5
5
 
6
6
  ## Problem
@@ -1,6 +1,6 @@
1
1
  # ADR-017: Validate a temporary RDF union without changing canonical evidence
2
2
 
3
- Status: accepted and implemented for the focused 0.3.0 release plan; absent from 0.2.1.
3
+ Status: accepted and implemented in the focused 0.3.0 release; absent from 0.2.1.
4
4
  See [validation](VALIDATION.md) for checks and recorded publication evidence.
5
5
 
6
6
  ## Problem and measured mechanism
@@ -0,0 +1,100 @@
1
+ # ADR-018: Preserve Codex artifacts across operational configuration changes
2
+
3
+ Status: accepted and implemented in development source after 0.3.0; release evidence is recorded
4
+ in [validation](VALIDATION.md). This increment does not claim a new npm publication.
5
+
6
+ ## Problem
7
+
8
+ With `storageScope: "user"`, the storage profile includes the entire configuration and checkout
9
+ path. Increasing a timeout therefore selected a different directory even though the built-in
10
+ Codex artifact key already excluded that timeout. A partially completed run could lose access
11
+ to reusable work without any source or extraction-contract change.
12
+
13
+ Custom provider modules present a different boundary: they receive the entire semantic
14
+ configuration and may use it to affect their output. Their identities must remain conservative.
15
+
16
+ ## Decision
17
+
18
+ Keep `resolveStorageRoot`, snapshot profiles, snapshot IDs and full configuration digests unchanged.
19
+ For built-in Codex with user storage, use a separate semantic-artifact partition. Its identity
20
+ includes the resolved checkout and full configuration except these five semantic controls:
21
+
22
+ | Setting | Why it can share successful Codex artifacts |
23
+ | --- | --- |
24
+ | `timeoutMs` | Bounds execution; it does not change the prompt or model arguments |
25
+ | `retries` | Limits retries of supported transient failures |
26
+ | `concurrency` | Schedules independent source extractions |
27
+ | `maxDocuments` | Bounds the selected workload |
28
+ | `maxSourceBytes` | Bounds each source; it never truncates its contents |
29
+
30
+ Document and byte limits still apply before hydration, including when every artifact is cached.
31
+ Changing a limit does not waive it. Model/reasoning settings, prompt/schema/extractor/adapter/
32
+ ontology identities, confidence/unresolved policy, authentication descriptors, source identity
33
+ and bytes continue to invalidate reuse through their existing artifact keys and/or partition.
34
+ Unknown configuration fields remain identity inputs. Credential environment values are not read
35
+ or added to the cache partition.
36
+
37
+ The new user-cache layout is `~/.cache/context-graph/semantic-artifacts-v1/<partition>/artifacts/semantic/`.
38
+ The partition uses a full SHA-256 digest. Different checkouts, repository IDs, namespaces and
39
+ non-operational configurations remain separate. Workspace storage retains its existing paths
40
+ and behavior; it already allowed built-in Codex reuse after these operational changes. Modules
41
+ retain their full semantic-config identity and their existing storage profiles. Sharing a custom
42
+ workspace storage directory remains an explicit operator choice, not a new global cache feature.
43
+
44
+ Artifact keys and output digests retain the 0.2.1/0.3.0 contract, including the repository-document
45
+ identity policy introduced in 0.2.1. No incompatible 0.2.0 artifact is reinterpreted as current.
46
+ Source approval and assertion review remain distinct; this change never approves an assertion.
47
+
48
+ ## Legacy recovery without model work
49
+
50
+ The planner and hydrator use the same store factory. It may read the exact legacy user profile
51
+ derived from the current full configuration after checking the new primary store. A corrupt or
52
+ unreadable primary entry stops reuse; a valid legacy entry does not mask it. Planning remains
53
+ read-only. Hydration can atomically retain a validated legacy artifact in the new partition, then
54
+ revalidate the retained entry to handle concurrent winners. Original files, artifact identities,
55
+ source provenance and raw provider output remain intact.
56
+
57
+ Legacy profile directories contain no reliable checkout/configuration ownership manifest, and an
58
+ interrupted run may have no snapshot. Consequently, the toolkit never scans sibling profiles for
59
+ a matching key. If settings were already changed, the old location cannot safely be inferred.
60
+
61
+ The explicit `cache adopt --from-config <previous.json>` command handles that case without
62
+ providers, authentication probes, indexing, ontology compilation, adapters or snapshot writes.
63
+ Both configurations must resolve to the same checkout and have the same non-operational
64
+ partition identity. Keep the exact old configuration in its original configuration directory.
65
+ Relocation is safe only when its original, unchanged `repositoryRoot` still resolves to the same
66
+ checkout (for example, an absolute root). Do not edit that field to compensate for relocation:
67
+ it is part of the old profile identity. Preserve the original field values and omissions.
68
+
69
+ Adoption resolves a committed ref and uses the same configured/agent selection as the hydration
70
+ preview. It looks up only expected keys in the exact old profile, checks the complete artifact
71
+ contract and output digest, and copies valid candidates atomically. `--dry-run` performs no writes.
72
+ Reports identify reused/adopted/planned/missing/corrupt/unavailable/conflicting entries, configured
73
+ budgets and scope omissions. They omit source bodies, raw provider output and configuration values.
74
+
75
+ Recovery is per artifact: valid copies can complete even if another entry is corrupt or conflicts.
76
+ The report records partial progress and exits unsuccessfully for problems or incomplete scope.
77
+ Missing artifacts are ordinary future extraction work. Corrupt complete artifacts are retained
78
+ for diagnosis; they require an explicit forced refresh or operator repair, not automatic overwrite.
79
+ Incomplete temporary files are not published artifacts and do not satisfy a cache lookup.
80
+
81
+ Concurrent writers use immutable publication. Different outputs under one key are reported as
82
+ conflicts; a matching `outputDigest` field alone is insufficient to accept malformed metadata.
83
+ Explicit forced extraction still creates a separate revision and preserves existing artifacts.
84
+
85
+ ## Freshness and limits
86
+
87
+ Semantic reuse does not reuse a snapshot's validation result, skip SCIP or SHACL, or let old
88
+ configuration evidence satisfy current validation. A timeout change still changes the snapshot's
89
+ full configuration digest and user storage profile. Saved evidence remains available under its
90
+ original configuration; this increment does not consolidate historical snapshot directories.
91
+
92
+ The current module-provider contract does not promise operational independence. Supporting that
93
+ requires a separate tested contract, not silently dropping fields from a provider's identity.
94
+ Credential rotation under an unchanged environment-variable name is not detected by this cache;
95
+ credentials are neither cache contents nor cache-identity inputs. Existing filesystem access
96
+ controls and the configured storage location remain the security boundary.
97
+
98
+ Validation uses public fixtures, deterministic Codex runners and installed TypeScript indexing.
99
+ Live semantic quality and private consumer acceptance remain separate. See the
100
+ [1.0 plan](RELEASE-1.0-PLAN.md) for timeout diagnostics and document coverage follow-ups.