@sungen/driver-data-factory 3.2.12-beta.3 → 3.2.12-beta.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sungen/driver-data-factory",
3
- "version": "3.2.12-beta.3",
3
+ "version": "3.2.12-beta.4",
4
4
  "description": "Sungen Data Factory capability — standardized test-data generation from a field-type + security knowledge catalog (valid/boundary/invalid + traceability), the .overwrite merge layer, and (opt-in) @dataFactory runtime seeding. Design-time knowledge is runtime-free. Plugs into @sun-asterisk/sungen via the capability SPI.",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -14,7 +14,7 @@
14
14
  "author": "eqe team (engineer & quality) — Sun Asterisk",
15
15
  "license": "MIT",
16
16
  "dependencies": {
17
- "@sun-asterisk/sungen": "3.2.12-beta.3",
17
+ "@sun-asterisk/sungen": "3.2.12-beta.4",
18
18
  "yaml": "^2.4.0"
19
19
  },
20
20
  "files": [
@@ -14,7 +14,7 @@ import { readFileSync, readdirSync, existsSync, statSync } from 'fs';
14
14
  import { join } from 'path';
15
15
  import { createHash } from 'crypto';
16
16
  import { parse as parseYaml } from 'yaml';
17
- import type { Catalog, FieldType, SecurityBank, Technique, DataPoint, OverwriteWarning } from './types';
17
+ import type { Catalog, FieldType, SecurityBank, Technique, DataPoint, OverwriteWarning, CatalogProvenance, FieldTypeProvenance } from './types';
18
18
 
19
19
  /** Resolve the packaged `common/` dir (works from src/ in dev via tsx and from dist/ when built). */
20
20
  export function commonDir(): string {
@@ -52,27 +52,63 @@ export function loadShippedCatalog(root = commonDir()): Catalog {
52
52
  return { fieldTypes, security, techniques };
53
53
  }
54
54
 
55
- /** Merge a field-type's group by DataPoint.id — overlay entries replace same-id, new ids append. */
56
- function mergeGroup(base: DataPoint[] = [], overlay: DataPoint[] = []): DataPoint[] {
55
+ /**
56
+ * Merge a field-type group by DataPoint.id, honoring three explicit operations:
57
+ * - EXTEND: an overlay id not in `base` is appended (`added`).
58
+ * - REPLACE: an overlay id already in `base` wins (`replaced`).
59
+ * - DISABLE: an id in `disableSet` is dropped (`disabled`).
60
+ * Records provenance and stamps each point's `source` so a reader can trace where it came from.
61
+ */
62
+ function mergeGroup(
63
+ base: DataPoint[] = [],
64
+ overlay: DataPoint[] = [],
65
+ disableSet: Set<string>,
66
+ prov: FieldTypeProvenance,
67
+ ): DataPoint[] {
57
68
  const byId = new Map<string, DataPoint>();
58
- for (const dp of base) byId.set(dp.id, dp);
59
- for (const dp of overlay) byId.set(dp.id, { ...byId.get(dp.id), ...dp });
69
+ for (const dp of base) byId.set(dp.id, { ...dp, source: 'shipped' });
70
+ for (const dp of overlay) {
71
+ if (byId.has(dp.id)) prov.replaced.push(dp.id);
72
+ else prov.added.push(dp.id);
73
+ byId.set(dp.id, { ...byId.get(dp.id), ...dp, source: 'overwrite' });
74
+ }
75
+ for (const id of disableSet) {
76
+ if (byId.delete(id)) prov.disabled.push(id);
77
+ }
60
78
  return [...byId.values()];
61
79
  }
62
80
 
63
- function mergeFieldType(base: FieldType, overlay: Partial<FieldType>): FieldType {
81
+ /** An overlay field-type entry may carry a `disable:` list of ids alongside partial field-type data. */
82
+ type FieldTypeOverlay = Partial<FieldType> & { disable?: string[] };
83
+
84
+ function mergeFieldType(base: FieldType, overlay: FieldTypeOverlay, prov: FieldTypeProvenance): FieldType {
85
+ const disableSet = new Set(overlay.disable || []);
86
+ const constraintsOverridden = Object.keys(overlay.constraints || {}).filter(
87
+ (k) => (base.constraints || {})[k] !== (overlay.constraints || {})[k],
88
+ );
89
+ prov.constraintsOverridden = constraintsOverridden;
90
+ prov.securityChanged = overlay.uses_security != null;
91
+ const { disable: _disable, ...rest } = overlay;
64
92
  return {
65
93
  ...base,
66
- ...overlay,
94
+ ...rest,
67
95
  constraints: { ...(base.constraints || {}), ...(overlay.constraints || {}) },
68
96
  uses_security: overlay.uses_security ?? base.uses_security,
69
- valid: mergeGroup(base.valid, overlay.valid),
70
- boundary: mergeGroup(base.boundary, overlay.boundary),
71
- invalid: mergeGroup(base.invalid, overlay.invalid),
97
+ valid: mergeGroup(base.valid, overlay.valid, disableSet, prov),
98
+ boundary: mergeGroup(base.boundary, overlay.boundary, disableSet, prov),
99
+ invalid: mergeGroup(base.invalid, overlay.invalid, disableSet, prov),
72
100
  notes: [...(base.notes || []), ...(overlay.notes || [])],
73
101
  };
74
102
  }
75
103
 
104
+ /** Merge a security bank by payload id (same rules: replace same-id, append new). */
105
+ function mergeSecurity(base: SecurityBank, overlay: Partial<SecurityBank>): SecurityBank {
106
+ const byId = new Map<string, any>();
107
+ for (const p of base.payloads || []) byId.set(p.id, p);
108
+ for (const p of overlay.payloads || []) byId.set(p.id, { ...byId.get(p.id), ...p });
109
+ return { ...base, ...overlay, payloads: [...byId.values()] };
110
+ }
111
+
76
112
  /**
77
113
  * Load the effective catalog = shipped common + project overwrite overlay.
78
114
  * `projectDir` is the QA repo root; overlay is read from `qa/data-factory/common.overwrite.yaml`.
@@ -80,22 +116,32 @@ function mergeFieldType(base: FieldType, overlay: Partial<FieldType>): FieldType
80
116
  export function loadEffectiveCatalog(projectDir: string, root = commonDir()): {
81
117
  catalog: Catalog;
82
118
  warnings: OverwriteWarning[];
119
+ provenance: CatalogProvenance;
83
120
  } {
84
121
  const shipped = loadShippedCatalog(root);
85
122
  const overlayPath = join(projectDir, 'qa', 'data-factory', 'common.overwrite.yaml');
86
123
  const warnings: OverwriteWarning[] = [];
87
- if (!existsSync(overlayPath)) return { catalog: shipped, warnings };
124
+ const provenance: CatalogProvenance = { fieldTypes: {}, newFieldTypes: [], security: { added: [], replaced: [] }, techniques: { added: [], replaced: [] } };
125
+ if (!existsSync(overlayPath)) return { catalog: shipped, warnings, provenance };
88
126
 
89
127
  const overlay = parseYaml(readFileSync(overlayPath, 'utf8')) || {};
90
- const overlayFieldTypes: Record<string, Partial<FieldType>> = overlay.fieldTypes || overlay['field-types'] || {};
128
+ const overlayFieldTypes: Record<string, FieldTypeOverlay> = overlay.fieldTypes || overlay['field-types'] || {};
91
129
  const merged: Catalog = {
92
130
  fieldTypes: { ...shipped.fieldTypes },
93
131
  security: { ...shipped.security },
94
132
  techniques: { ...shipped.techniques },
95
133
  };
134
+
96
135
  for (const [type, ov] of Object.entries(overlayFieldTypes)) {
97
136
  if (merged.fieldTypes[type]) {
98
- merged.fieldTypes[type] = mergeFieldType(merged.fieldTypes[type], ov);
137
+ const prov: FieldTypeProvenance = { type, disabled: [], replaced: [], added: [], constraintsOverridden: [], securityChanged: false };
138
+ // Warn on a disable target that does not exist in the shipped field-type (likely a typo/stale id).
139
+ const known = new Set([...(merged.fieldTypes[type].valid || []), ...(merged.fieldTypes[type].boundary || []), ...(merged.fieldTypes[type].invalid || [])].map((d) => d.id));
140
+ for (const id of ov.disable || []) {
141
+ if (!known.has(id)) warnings.push({ fieldType: type, detail: `disable target "${id}" does not exist in the shipped "${type}" catalog`, suggestion: `remove the stale id from the "${type}".disable list in qa/data-factory/common.overwrite.yaml` });
142
+ }
143
+ merged.fieldTypes[type] = mergeFieldType(merged.fieldTypes[type], ov, prov);
144
+ provenance.fieldTypes[type] = prov;
99
145
  // Drift warning: overlay references a security bank the shipped common no longer has.
100
146
  for (const bank of ov.uses_security || []) {
101
147
  if (!merged.security[bank]) {
@@ -108,10 +154,45 @@ export function loadEffectiveCatalog(projectDir: string, root = commonDir()): {
108
154
  }
109
155
  } else {
110
156
  // Wholly new field-type from the user — accept as-is (project-specific type).
111
- merged.fieldTypes[type] = ov as FieldType;
157
+ const { disable: _d, ...rest } = ov;
158
+ merged.fieldTypes[type] = rest as FieldType;
159
+ provenance.newFieldTypes.push(type);
112
160
  }
113
161
  }
114
- return { catalog: merged, warnings };
162
+
163
+ // Security-bank overrides (`security:` in the overwrite) — replace same-id payloads, append new banks.
164
+ const overlaySecurity: Record<string, Partial<SecurityBank>> = overlay.security || {};
165
+ for (const [cls, ov] of Object.entries(overlaySecurity)) {
166
+ if (merged.security[cls]) { merged.security[cls] = mergeSecurity(merged.security[cls], ov); provenance.security.replaced.push(cls); }
167
+ else { merged.security[cls] = { class: cls, payloads: [], ...ov } as SecurityBank; provenance.security.added.push(cls); }
168
+ }
169
+
170
+ // Technique overrides (`techniques:` in the overwrite) — shallow replace/add by name.
171
+ const overlayTech: Record<string, Partial<Technique>> = overlay.techniques || {};
172
+ for (const [name, ov] of Object.entries(overlayTech)) {
173
+ if (merged.techniques[name]) { merged.techniques[name] = { ...merged.techniques[name], ...ov }; provenance.techniques.replaced.push(name); }
174
+ else { merged.techniques[name] = { technique: name, ...ov } as Technique; provenance.techniques.added.push(name); }
175
+ }
176
+
177
+ return { catalog: merged, warnings, provenance };
178
+ }
179
+
180
+ /** Human-readable one-liners describing what `.overwrite` changed (for lint/sensor "no silent overwrite"). */
181
+ export function describeProvenance(p: CatalogProvenance): string[] {
182
+ const lines: string[] = [];
183
+ for (const ft of Object.values(p.fieldTypes)) {
184
+ const parts: string[] = [];
185
+ if (ft.disabled.length) parts.push(`disabled ${ft.disabled.join(', ')}`);
186
+ if (ft.replaced.length) parts.push(`replaced ${ft.replaced.join(', ')}`);
187
+ if (ft.added.length) parts.push(`added ${ft.added.join(', ')}`);
188
+ if (ft.constraintsOverridden.length) parts.push(`constraints[${ft.constraintsOverridden.join(', ')}]`);
189
+ if (ft.securityChanged) parts.push('uses_security replaced');
190
+ if (parts.length) lines.push(`${ft.type}: ${parts.join('; ')}`);
191
+ }
192
+ for (const t of p.newFieldTypes) lines.push(`${t}: new project field-type`);
193
+ if (p.security.replaced.length || p.security.added.length) lines.push(`security: ${[...p.security.replaced.map((s) => `replaced ${s}`), ...p.security.added.map((s) => `added ${s}`)].join('; ')}`);
194
+ if (p.techniques.replaced.length || p.techniques.added.length) lines.push(`techniques: ${[...p.techniques.replaced.map((s) => `replaced ${s}`), ...p.techniques.added.map((s) => `added ${s}`)].join('; ')}`);
195
+ return lines;
115
196
  }
116
197
 
117
198
  /** Stable hash of the shipped common — recorded in df-manifest.json to detect update drift. */
package/src/cli.ts CHANGED
@@ -10,7 +10,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from
10
10
  import { join, dirname } from 'path';
11
11
  import { createInterface } from 'readline';
12
12
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
13
- import { loadEffectiveCatalog } from './catalog-loader';
13
+ import { loadEffectiveCatalog, describeProvenance } from './catalog-loader';
14
14
  import { expandFieldMap, renderTestData, type FieldMap } from './generator';
15
15
  import { lintFieldMap, summarize } from './lint';
16
16
  import { validateGenerated } from './validate';
@@ -51,8 +51,9 @@ function loadFieldMap(cwd: string, name: string): FieldMap | null {
51
51
  async function genOne(cwd: string, name: string, opts: { force?: boolean }): Promise<'written' | 'skipped' | 'missing'> {
52
52
  const map = loadFieldMap(cwd, name);
53
53
  if (!map) { console.error(` ✗ ${name}: no field-map at qa/data-factory/${name}.fields.yaml (author it first, e.g. via /sungen:create-data-test)`); return 'missing'; }
54
- const { catalog, warnings } = loadEffectiveCatalog(cwd);
54
+ const { catalog, warnings, provenance } = loadEffectiveCatalog(cwd);
55
55
  for (const w of warnings) console.warn(` ⚠ overwrite: ${w.detail} → ${w.suggestion}`);
56
+ for (const line of describeProvenance(provenance)) console.log(` ⟲ .overwrite → ${line}`);
56
57
 
57
58
  const expanded = expandFieldMap(map, catalog);
58
59
  const out = testDataPath(cwd, name);
@@ -174,6 +175,23 @@ export function registerDataFactoryCommand(program: any): void {
174
175
  if (totalErrors) process.exitCode = 1;
175
176
  });
176
177
 
178
+ data
179
+ .command('provenance')
180
+ .description('Show what qa/data-factory/common.overwrite.yaml changed vs the shipped company catalog (extend/replace/disable) — "no silent overwrite"')
181
+ .action(() => {
182
+ const cwd = process.cwd();
183
+ ensureDataFactoryReadme(cwd);
184
+ const { provenance, warnings } = loadEffectiveCatalog(cwd);
185
+ const lines = describeProvenance(provenance);
186
+ if (lines.length === 0) {
187
+ console.log('No project overrides — the effective catalog is the shipped company common as-is.');
188
+ } else {
189
+ console.log('Project overrides (qa/data-factory/common.overwrite.yaml):');
190
+ for (const l of lines) console.log(` ⟲ ${l}`);
191
+ }
192
+ for (const w of warnings) console.warn(` ⚠ ${w.detail} → ${w.suggestion}`);
193
+ });
194
+
177
195
  data
178
196
  .command('fixture')
179
197
  .description('Generate a CSV import fixture from the field-map (bulk-import / file-upload testing)')
package/src/generator.ts CHANGED
@@ -71,6 +71,8 @@ export interface ExpandedPoint {
71
71
  * literal charset/format invalid (or a security payload) legitimately satisfies the length oracle.
72
72
  */
73
73
  synthesized?: boolean;
74
+ /** Provenance: `overwrite` when the project's `.overwrite` replaced/added this point (else shipped). */
75
+ source?: 'shipped' | 'overwrite';
74
76
  }
75
77
 
76
78
  export interface ExpandedField {
@@ -84,6 +86,27 @@ export interface ExpandedField {
84
86
  dependsOn?: { field: string; value?: string };
85
87
  }
86
88
 
89
+ /**
90
+ * Coerce an out-of-range numeric VALID literal into range, so a project's `valid` representative is
91
+ * actually valid for its constraints (a generic catalog example like `123` under `max: 100` becomes
92
+ * `100`). Returns null when in range already, or the type isn't numeric (non-numeric mismatches are
93
+ * left for the validator to flag — the field-map should supply a fitting value).
94
+ */
95
+ function coerceValidNumeric(type: string, value: string, c: Record<string, any>): string | null {
96
+ if (!['number-integer', 'number-float', 'currency'].includes(type)) return null;
97
+ const n = Number(value);
98
+ if (!Number.isFinite(n)) return null;
99
+ let out = n;
100
+ if (Number.isFinite(c.min) && out < c.min) out = c.min;
101
+ if (Number.isFinite(c.max) && out > c.max) out = c.max;
102
+ if (out === n) return null; // already in range
103
+ if (type === 'number-float' || (type === 'currency' && c.decimalPlaces > 0)) {
104
+ const dp = Number.isFinite(c.decimalPlaces) ? c.decimalPlaces : 2;
105
+ return out.toFixed(dp);
106
+ }
107
+ return String(Math.round(out));
108
+ }
109
+
87
110
  /** A gen hint that references a numeric/length boundary the generator can expand structurally. */
88
111
  function isBoundaryHint(hint: string): boolean {
89
112
  return /\bmin\b|\bmax\b|minimum|maximum|length|byte|exact|decimal|precision|local|total|over|below|under|exceed|\+\s*1|-\s*1|<|>/.test(
@@ -138,19 +161,35 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
138
161
  const emit = (p: Omit<ExpandedPoint, 'expectedValidity'> & { expectedValidity: Validity }) => points.push(p);
139
162
 
140
163
  const pushLiteral = (dp: DataPoint, group: ExpandedPoint['group'], value: string | null, opts: { validity?: Validity; label?: string; todo?: string } = {}) => {
164
+ // A valid representative must be valid for the project: clamp an out-of-range numeric literal.
165
+ let coerceNote: string | undefined;
166
+ if (group === 'valid' && value != null && satisfiesConstraints(type, value, c) === false) {
167
+ const fixed = coerceValidNumeric(type, value, c);
168
+ if (fixed != null) { value = fixed; coerceNote = 'adjusted to fit constraints'; }
169
+ }
141
170
  const isUniqueValid = group === 'valid' && field.unique && value != null;
142
171
  const v = isUniqueValid ? makeUnique(value!, type) : value;
143
- // Oracle: if the constraints can judge this value, they win; else fall back to declared/group validity.
144
- const oracle = satisfiesConstraints(type, isUniqueValid ? value : v, c);
145
- const validity: Validity = dp.validity ?? (oracle == null ? (opts.validity ?? groupValidity(group)) : oracle ? 'valid' : 'invalid');
172
+ // Expected validity reflects INTENT by group, so the validator (validateGenerated) can catch a
173
+ // mismatch against the oracle instead of the mismatch being silently absorbed here:
174
+ // - valid group → `valid` (a value that actually violates the constraints is a VALUE_NOT_VALID).
175
+ // - invalid group → `invalid` (declared: charset/format/blocklist/behavior — the length oracle
176
+ // cannot "prove" such a literal valid, so it must NOT flip it to valid).
177
+ // - boundary group → the oracle decides (that group is precisely about range).
178
+ let validity: Validity;
179
+ if (dp.validity) validity = dp.validity;
180
+ else if (group === 'boundary') {
181
+ const oracle = satisfiesConstraints(type, isUniqueValid ? value : v, c);
182
+ validity = oracle == null ? (opts.validity ?? 'valid') : oracle ? 'valid' : 'invalid';
183
+ } else validity = opts.validity ?? groupValidity(group);
146
184
  emit({
147
185
  id: dp.id,
148
186
  group,
149
187
  value: v,
150
188
  todo: opts.todo,
151
189
  reason: dp.reason,
152
- note: isUniqueValid ? `${dp.note ? dp.note + ' · ' : ''}fresh per run` : (opts.label ? `${dp.note ? dp.note + ' · ' : ''}${opts.label}` : dp.note),
190
+ note: isUniqueValid ? `${dp.note ? dp.note + ' · ' : ''}fresh per run` : coerceNote ? `${dp.note ? dp.note + ' · ' : ''}${coerceNote}` : (opts.label ? `${dp.note ? dp.note + ' · ' : ''}${opts.label}` : dp.note),
153
191
  expectedValidity: validity,
192
+ ...(dp.source === 'overwrite' ? { source: 'overwrite' as const } : {}),
154
193
  ...resolveError(dp, field.errorMap),
155
194
  });
156
195
  };
@@ -173,6 +212,7 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
173
212
  note: bp.label && !bp.todo ? `${dp.note ? dp.note + ' · ' : ''}${bp.label}` : dp.note,
174
213
  expectedValidity: dp.validity ?? bp.validity,
175
214
  synthesized: bp.value != null,
215
+ ...(dp.source === 'overwrite' ? { source: 'overwrite' as const } : {}),
176
216
  ...resolveError(dp, field.errorMap),
177
217
  });
178
218
  }
@@ -244,8 +284,8 @@ function renderOne(f: ExpandedField): Record<string, any> {
244
284
  return {
245
285
  ...dep,
246
286
  valid: valid[0]?.value ?? null,
247
- boundary: boundary.map((p) => trim({ id: p.id, value: p.value, expected: p.expectedValidity, note: p.note, todo: p.todo })),
248
- invalid: invalid.map((p) => trim({ id: p.id, value: p.value, expected: p.expectedValidity, expected_error: p.expectedError, open: p.open, reason: p.reason, todo: p.todo })),
287
+ boundary: boundary.map((p) => trim({ id: p.id, value: p.value, expected: p.expectedValidity, via: p.source === 'overwrite' ? '.overwrite' : undefined, note: p.note, todo: p.todo })),
288
+ invalid: invalid.map((p) => trim({ id: p.id, value: p.value, expected: p.expectedValidity, via: p.source === 'overwrite' ? '.overwrite' : undefined, expected_error: p.expectedError, open: p.open, reason: p.reason, todo: p.todo })),
249
289
  };
250
290
  }
251
291
 
package/src/index.ts CHANGED
@@ -18,7 +18,7 @@ import { registerDataFactoryCommand } from './cli';
18
18
  import { dataFactoryCoverageSensor } from './sensor';
19
19
  import { dfPreconditionCodegen } from './precondition';
20
20
 
21
- export { loadShippedCatalog, loadEffectiveCatalog, shippedCommonHash, commonDir } from './catalog-loader';
21
+ export { loadShippedCatalog, loadEffectiveCatalog, shippedCommonHash, commonDir, describeProvenance } from './catalog-loader';
22
22
  export { expandField, expandFieldMap, renderTestData } from './generator';
23
23
  export type { FieldDef, FieldMap, ExpandedField, ExpandedPoint } from './generator';
24
24
  export { lintFieldMap, summarize } from './lint';
@@ -32,7 +32,7 @@ export { generateFixture } from './fixture';
32
32
  export type { FixtureResult } from './fixture';
33
33
  export { dataFactoryCoverageSensor } from './sensor';
34
34
  export { dfPreconditionCodegen } from './precondition';
35
- export type { Catalog, FieldType, SecurityBank, Technique, DataPoint, OverwriteWarning } from './types';
35
+ export type { Catalog, FieldType, SecurityBank, Technique, DataPoint, OverwriteWarning, CatalogProvenance, FieldTypeProvenance } from './types';
36
36
 
37
37
  /** Register the Data Factory capability. */
38
38
  export function register(registry: CapabilityRegistry): void {
package/src/types.ts CHANGED
@@ -24,6 +24,12 @@ export interface DataPoint {
24
24
  * (e.g. a format-invalid value whose length happens to sit inside the range).
25
25
  */
26
26
  validity?: 'valid' | 'invalid';
27
+ /**
28
+ * Where this point ended up coming from after the `.overwrite` merge — `shipped` (company common) or
29
+ * `overwrite` (the project replaced/added it). Set by the loader; carried through to the generated
30
+ * value so a reader can answer "why is this value here?". Not authored by hand.
31
+ */
32
+ source?: 'shipped' | 'overwrite';
27
33
  }
28
34
 
29
35
  export interface FieldType {
@@ -66,3 +72,27 @@ export interface OverwriteWarning {
66
72
  detail: string;
67
73
  suggestion: string;
68
74
  }
75
+
76
+ /** What the `.overwrite` layer changed for one field-type (provenance — "no silent overwrite"). */
77
+ export interface FieldTypeProvenance {
78
+ type: string;
79
+ /** Ids removed via `disable:` (an id that was not in the shipped common is reported as a warning). */
80
+ disabled: string[];
81
+ /** Ids that existed in shipped AND the overlay — the overlay value won. */
82
+ replaced: string[];
83
+ /** Ids new from the overlay (a project-specific case). */
84
+ added: string[];
85
+ /** Constraint keys the overlay changed. */
86
+ constraintsOverridden: string[];
87
+ /** True when the overlay replaced `uses_security`. */
88
+ securityChanged: boolean;
89
+ }
90
+
91
+ /** Catalog-wide provenance of the shipped-common → project-overwrite merge. */
92
+ export interface CatalogProvenance {
93
+ fieldTypes: Record<string, FieldTypeProvenance>;
94
+ /** Wholly new field-types added by the project (not in shipped common). */
95
+ newFieldTypes: string[];
96
+ security: { added: string[]; replaced: string[] };
97
+ techniques: { added: string[]; replaced: string[] };
98
+ }