From 2910d6636dff7257343669903ea4674edcd66a7d Mon Sep 17 00:00:00 2001 From: James Mountifield Date: Wed, 29 Apr 2026 13:26:06 +0100 Subject: [PATCH 1/3] feat(validator,testing): permissive-enum warning passthrough for NHI schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S01: validator partitions ajv errors into hard errors vs permissive-enum warnings (NHI _nhiType / _aiPlatform / _nhiOwnerStatus). Returns { errors, warnings } and exposes setSchemaSingleton for test injection. S02: toMatchDataModelSchema matcher consumes the new shape — fails only on hard errors, emits a single console.warn summarising warnings across all entities. Adds tests for warning-only, mixed, and clean cases. --- .../src/__tests__/validator.test.ts | 218 ++++++++++++++++++ .../src/index.ts | 6 +- .../src/validator.ts | 53 ++++- .../src/__tests__/jest.test.ts | 113 ++++++++- packages/integration-sdk-testing/src/jest.ts | 34 ++- 5 files changed, 408 insertions(+), 16 deletions(-) diff --git a/packages/integration-sdk-entity-validator/src/__tests__/validator.test.ts b/packages/integration-sdk-entity-validator/src/__tests__/validator.test.ts index bf7fbaebb..ae68f82de 100644 --- a/packages/integration-sdk-entity-validator/src/__tests__/validator.test.ts +++ b/packages/integration-sdk-entity-validator/src/__tests__/validator.test.ts @@ -75,6 +75,7 @@ describe('validator', () => { ).toEqual({ isValid: true, errors: null, + warnings: null, skippedSchemas: [ { schemaId: '#type', @@ -104,6 +105,7 @@ describe('validator', () => { ).toEqual({ isValid: true, errors: null, + warnings: null, skippedSchemas: [ { schemaId: '#type', @@ -134,6 +136,7 @@ describe('validator', () => { property: '_key', }, ], + warnings: null, skippedSchemas: [ { schemaId: '#type', @@ -155,6 +158,7 @@ describe('validator', () => { ).toEqual({ isValid: true, errors: null, + warnings: null, skippedSchemas: [ { schemaId: '#GraphObject', @@ -180,6 +184,7 @@ describe('validator', () => { ).toEqual({ isValid: true, errors: null, + warnings: null, skippedSchemas: [ { schemaId: '#GraphObject', @@ -208,6 +213,7 @@ describe('validator', () => { ).toEqual({ isValid: true, errors: null, + warnings: null, skippedSchemas: null, }); }); @@ -229,6 +235,7 @@ describe('validator', () => { property: '_key', }, ], + warnings: null, skippedSchemas: [ { schemaId: '#GraphObject', @@ -257,6 +264,7 @@ describe('validator', () => { property: '_key', }, ], + warnings: null, skippedSchemas: [ { schemaId: '#GraphObject', @@ -279,6 +287,7 @@ describe('validator', () => { ).toEqual({ isValid: true, errors: null, + warnings: null, skippedSchemas: [ { schemaId: '#GraphObject', @@ -301,6 +310,7 @@ describe('validator', () => { ).toEqual({ isValid: true, errors: null, + warnings: null, skippedSchemas: [ { schemaId: '#GraphObject', @@ -323,6 +333,7 @@ describe('validator', () => { ).toEqual({ isValid: true, errors: null, + warnings: null, skippedSchemas: [ { schemaId: '#GraphObject', @@ -352,6 +363,7 @@ describe('validator', () => { validation: 'format', }, ], + warnings: null, skippedSchemas: [ { schemaId: '#GraphObject', @@ -362,3 +374,209 @@ describe('validator', () => { }); }); }); + +describe('validator — permissive enum properties', () => { + // In-memory NHI-shaped schema. The shape mirrors the data-model's NHI class + // additions in M001/S02: `_nhiType`, `_nhiOwnerStatus`, `_aiConfidence` are + // enum-constrained; `_isAi` is a bare boolean (type, not enum). + const NHI_SCHEMA = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: '#NHI', + type: 'object', + properties: { + _key: { type: 'string', minLength: 10 }, + _class: { + oneOf: [ + { type: 'string', minLength: 2 }, + { + type: 'array', + minItems: 1, + items: { type: 'string', minLength: 2 }, + }, + ], + }, + _type: { type: 'string', minLength: 3 }, + _nhiType: { + type: 'string', + enum: ['service_account', 'api_key', 'workload_identity'], + }, + _nhiOwnerStatus: { + type: 'string', + enum: ['active', 'inactive', 'orphaned'], + }, + _aiConfidence: { + type: 'string', + enum: ['high', 'medium', 'low'], + }, + _isAi: { type: 'boolean' }, + }, + required: ['_key', '_class', '_type'], + }; + + const validNhiEntity = { + _class: ['NHI'], + _type: 'NHI', + _key: '0123456789', + _nhiType: 'service_account', + _nhiOwnerStatus: 'active', + _aiConfidence: 'high', + _isAi: true, + }; + + test('valid NHI entity produces no errors and no warnings', () => { + const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); + expect(validator.validateEntity(validNhiEntity)).toEqual({ + isValid: true, + errors: null, + warnings: null, + skippedSchemas: [ + { schemaId: '#NHI', reason: 'type-already-validated', type: 'class' }, + ], + }); + }); + + test('unknown _nhiType becomes a warning, not an error', () => { + const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); + const result = validator.validateEntity({ + ...validNhiEntity, + _nhiType: 'unknown_subtype', + }); + expect(result.isValid).toBe(true); + expect(result.errors).toBeNull(); + expect(result.warnings).toEqual([ + { + schemaId: '#NHI', + property: '_nhiType', + message: expect.stringContaining('must be equal to one of'), + validation: 'enum', + }, + ]); + }); + + test('unknown _nhiOwnerStatus becomes a warning', () => { + const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); + const result = validator.validateEntity({ + ...validNhiEntity, + _nhiOwnerStatus: 'mystery', + }); + expect(result.isValid).toBe(true); + expect(result.errors).toBeNull(); + expect(result.warnings).toHaveLength(1); + expect(result.warnings![0].property).toBe('_nhiOwnerStatus'); + expect(result.warnings![0].validation).toBe('enum'); + }); + + test('unknown _aiConfidence becomes a warning', () => { + const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); + const result = validator.validateEntity({ + ...validNhiEntity, + _aiConfidence: 'extremely high', + }); + expect(result.isValid).toBe(true); + expect(result.warnings).toHaveLength(1); + expect(result.warnings![0].property).toBe('_aiConfidence'); + }); + + test('type mismatch on a permissive property still hard-errors', () => { + // _isAi: 'yes' is a string where boolean is required. The property is not + // in the permissive set anyway, but the point is: keyword === 'type' is + // not 'enum', so even a permissive property would still hard-fail here. + const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); + const result = validator.validateEntity({ + ...validNhiEntity, + _isAi: 'yes', + }); + expect(result.isValid).toBe(false); + expect(result.warnings).toBeNull(); + expect(result.errors).toEqual([ + { + schemaId: '#NHI', + property: '_isAi', + message: expect.stringContaining('must be boolean'), + validation: 'type', + }, + ]); + }); + + test('type mismatch on a permissive enum property is still an error', () => { + // _nhiType is in the permissive set, but a number-shaped value triggers + // keyword === 'type' (not 'enum'), so it must hard-error. + const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); + const result = validator.validateEntity({ + ...validNhiEntity, + _nhiType: 42, + }); + expect(result.isValid).toBe(false); + expect(result.errors).toEqual([ + { + schemaId: '#NHI', + property: '_nhiType', + message: expect.stringContaining('must be string'), + validation: 'type', + }, + ]); + }); + + test('mixed: permissive enum violation + missing _key produces both warnings and errors', () => { + const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); + const { _key, ...entityWithoutKey } = validNhiEntity; + const result = validator.validateEntity({ + ...entityWithoutKey, + _nhiType: 'unknown_subtype', + }); + expect(result.isValid).toBe(false); + expect(result.errors).toEqual([ + { + schemaId: '#NHI', + property: '_key', + message: "must have required property '_key'", + validation: 'required', + }, + ]); + expect(result.warnings).toEqual([ + { + schemaId: '#NHI', + property: '_nhiType', + message: expect.stringContaining('must be equal to one of'), + validation: 'enum', + }, + ]); + }); + + test('permissiveEnumProperties: [] makes enum violations hard-error again', () => { + const validator = new EntityValidator({ + schemas: [NHI_SCHEMA], + permissiveEnumProperties: [], + }); + const result = validator.validateEntity({ + ...validNhiEntity, + _nhiType: 'unknown_subtype', + }); + expect(result.isValid).toBe(false); + expect(result.warnings).toBeNull(); + expect(result.errors).toEqual([ + { + schemaId: '#NHI', + property: '_nhiType', + message: expect.stringContaining('must be equal to one of'), + validation: 'enum', + }, + ]); + }); + + test('permissiveEnumProperties can be extended with additional properties', () => { + const validator = new EntityValidator({ + schemas: [NHI_SCHEMA], + permissiveEnumProperties: ['_nhiType', '_aiConfidence', '_customField'], + }); + // _nhiOwnerStatus is no longer permissive in this configuration. + const result = validator.validateEntity({ + ...validNhiEntity, + _nhiOwnerStatus: 'mystery', + }); + expect(result.isValid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors![0].property).toBe('_nhiOwnerStatus'); + expect(result.errors![0].validation).toBe('enum'); + }); +}); diff --git a/packages/integration-sdk-entity-validator/src/index.ts b/packages/integration-sdk-entity-validator/src/index.ts index d381b2380..6639f34c8 100644 --- a/packages/integration-sdk-entity-validator/src/index.ts +++ b/packages/integration-sdk-entity-validator/src/index.ts @@ -4,4 +4,8 @@ export { isEntityValidationError, type EntityValidationError, } from './entityValidationError'; -export { getValidator, getValidatorSync } from './singleton'; +export { + getValidator, + getValidatorSync, + setSchemaSingleton, +} from './singleton'; diff --git a/packages/integration-sdk-entity-validator/src/validator.ts b/packages/integration-sdk-entity-validator/src/validator.ts index 4111dcb9d..b94111e79 100644 --- a/packages/integration-sdk-entity-validator/src/validator.ts +++ b/packages/integration-sdk-entity-validator/src/validator.ts @@ -12,6 +12,7 @@ import { assertEntity } from './assertEntity'; type ValidateEntityResult = { isValid: boolean; errors: EntityValidationError[] | null; + warnings: EntityValidationError[] | null; skippedSchemas: SkippedSchema[] | null; }; @@ -25,6 +26,19 @@ type ValidateEntityOptions = { forceClassValidationWithValidatedType?: boolean; }; +/** + * Properties whose `enum` violations are reclassified as warnings instead of + * errors by default. New NHI subtypes, AI platforms, and ownership statuses + * appear in real integrations before the data-model is published, so a strict + * enum failure here would block ingestion. Type mismatches on these same + * properties still hard-fail. + */ +export const DEFAULT_PERMISSIVE_ENUM_PROPERTIES: readonly string[] = [ + '_nhiType', + '_nhiOwnerStatus', + '_aiConfidence', +]; + const convertUnknownErrorToEntityValidationError = ( error: unknown, ): EntityValidationError => { @@ -51,8 +65,20 @@ const convertUnknownErrorToEntityValidationError = ( export class EntityValidator { private ajvInstance: Ajv; - - constructor({ schemas }: { schemas?: AnySchema[] }) { + private permissiveEnumProperties: ReadonlySet; + + constructor({ + schemas, + permissiveEnumProperties = DEFAULT_PERMISSIVE_ENUM_PROPERTIES, + }: { + schemas?: AnySchema[]; + /** + * Property names whose `enum` violations are partitioned into + * `warnings` instead of `errors`. Pass `[]` to opt out of permissive + * behavior entirely. Defaults to {@link DEFAULT_PERMISSIVE_ENUM_PROPERTIES}. + */ + permissiveEnumProperties?: readonly string[]; + }) { this.ajvInstance = addJ1Formats( addFormats( new Ajv({ @@ -61,6 +87,7 @@ export class EntityValidator { }), ), ); + this.permissiveEnumProperties = new Set(permissiveEnumProperties); if (schemas) { this.addSchemas(schemas); @@ -87,6 +114,7 @@ export class EntityValidator { }: ValidateEntityOptions = {}, ): ValidateEntityResult { const errors: EntityValidationError[] = []; + const warnings: EntityValidationError[] = []; const skippedSchemas: SkippedSchema[] = []; try { @@ -126,11 +154,21 @@ export class EntityValidator { const isValid = validator(entity); if (!isValid) { - errors.push( - ...(validator.errors?.map((error) => - ajvErrorToEntityValidationError(schemaId, error), - ) ?? []), - ); + for (const ajvError of validator.errors ?? []) { + const validationError = ajvErrorToEntityValidationError( + schemaId, + ajvError, + ); + if ( + ajvError.keyword === 'enum' && + typeof validationError.property === 'string' && + this.permissiveEnumProperties.has(validationError.property) + ) { + warnings.push(validationError); + } else { + errors.push(validationError); + } + } } if (type === 'type' && !forceClassValidationWithValidatedType) { @@ -155,6 +193,7 @@ export class EntityValidator { return { isValid: errors.length === 0, errors: errors.length ? errors : null, + warnings: warnings.length ? warnings : null, skippedSchemas: skippedSchemas.length ? skippedSchemas : null, }; } diff --git a/packages/integration-sdk-testing/src/__tests__/jest.test.ts b/packages/integration-sdk-testing/src/__tests__/jest.test.ts index 176b6528b..916bd6b55 100644 --- a/packages/integration-sdk-testing/src/__tests__/jest.test.ts +++ b/packages/integration-sdk-testing/src/__tests__/jest.test.ts @@ -652,6 +652,115 @@ describe('#toMatchDataModelSchema', () => { expect(result.pass).toBe(false); }); + + test('should pass and emit a console.warn when entity violates a permissive enum (_aiConfidence)', () => { + const StringEnum = (values: [...T]) => + SchemaType.Unsafe({ + type: 'string', + enum: values, + }); + const [METADATA, createTestEntity] = createEntityMetadata({ + resourceName: 'AI Tool', + _class: ['Service'], + _type: 'permissive_enum_warning_test_pass', + description: 'Schema with a permissive enum property.', + schema: SchemaType.Object({ + _aiConfidence: StringEnum(['high', 'medium', 'low']), + }), + }); + + const entity = createTestEntity({ + name: 'thing', + function: ['other'], + _key: 'permissive-warn-pass-1', + displayName: 'Thing', + category: ['software'], + _aiConfidence: 'extreme' as unknown as 'high' | 'medium' | 'low', + }); + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = toMatchDataModelSchema(entity, METADATA); + expect(result.pass).toBe(true); + expect(warnSpy).toHaveBeenCalledTimes(1); + const warnMessage = warnSpy.mock.calls[0][0] as string; + expect(warnMessage).toContain('_aiConfidence'); + expect(warnMessage).toContain('permissive enum violations'); + } finally { + warnSpy.mockRestore(); + } + }); + + test('should fail when entity has a hard error even if it also has a permissive warning', () => { + const StringEnum = (values: [...T]) => + SchemaType.Unsafe({ + type: 'string', + enum: values, + }); + const [METADATA, createTestEntity] = createEntityMetadata({ + resourceName: 'AI Tool', + _class: ['Service'], + _type: 'permissive_enum_warning_test_mixed', + description: 'Schema with both a strict enum and a permissive enum.', + schema: SchemaType.Object({ + state: StringEnum(['ENABLED', 'DISABLED']), + _aiConfidence: StringEnum(['high', 'medium', 'low']), + }), + }); + + const entity = createTestEntity({ + name: 'thing', + function: ['other'], + _key: 'permissive-warn-mixed-1', + displayName: 'Thing', + category: ['software'], + state: 'BOGUS' as unknown as 'ENABLED' | 'DISABLED', + _aiConfidence: 'extreme' as unknown as 'high' | 'medium' | 'low', + }); + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = toMatchDataModelSchema(entity, METADATA); + expect(result.pass).toBe(false); + const message = (result.message as () => string)(); + expect(message).toContain('state'); + // warning is still surfaced even though we fail + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('_aiConfidence'); + } finally { + warnSpy.mockRestore(); + } + }); + + test('should not emit a console.warn when entity is fully clean', () => { + const [API_SERVICE, createApiService] = createEntityMetadata({ + resourceName: 'Google Cloud API Service', + _class: ['Service'], + _type: 'permissive_enum_clean_test', + description: 'Clean entity should produce no warnings.', + schema: SchemaType.Object({ + enabled: SchemaType.Boolean(), + }), + }); + + const entity = createApiService({ + name: 'clean', + function: ['other'], + _key: 'permissive-warn-clean-1', + displayName: 'Clean', + category: ['infrastructure'], + enabled: true, + }); + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = toMatchDataModelSchema(entity, API_SERVICE); + expect(result.pass).toBe(true); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); }); describe('#toMatchDirectRelationshipSchema', () => { @@ -1402,7 +1511,7 @@ describe('#toMatchEntityStepMetadata', () => { expect(result1).toMatchObject({ pass: false }); expect(result1.message()).toMatch( - 'Error validating object with data model schema: #google_cloud_api_service:usageRequirements:must be array', + 'Error validating object with data model schema: [0] #google_cloud_api_service:usageRequirements:must be array', ); const entity2 = createApiService({ @@ -1435,7 +1544,7 @@ describe('#toMatchEntityStepMetadata', () => { ); expect(result2).toMatchObject({ pass: false }); expect(result2.message()).toMatch( - "Error validating object with data model schema: #google_cloud_api_service:category:must have required property 'category'", + "Error validating object with data model schema: [0] #google_cloud_api_service:category:must have required property 'category'", ); }); }); diff --git a/packages/integration-sdk-testing/src/jest.ts b/packages/integration-sdk-testing/src/jest.ts index f2a554692..87c57f743 100644 --- a/packages/integration-sdk-testing/src/jest.ts +++ b/packages/integration-sdk-testing/src/jest.ts @@ -205,17 +205,39 @@ export function toMatchDataModelSchema( received = Array.isArray(received) ? received : [received]; + const allErrors: string[] = []; + const allWarnings: string[] = []; + for (let i = 0; i < received.length; i++) { - // if valid - const { isValid, errors = [] } = entityValidator.validateEntity( - received[i], - ); + const { errors, warnings } = entityValidator.validateEntity(received[i]); + + if (errors) { + for (const e of errors) { + allErrors.push( + `[${i}] ${e.schemaId}:${String(e.property)}:${e.message}`, + ); + } + } + if (warnings) { + for (const w of warnings) { + allWarnings.push( + `[${i}] ${w.schemaId}:${String(w.property)}:${w.message}`, + ); + } + } + } - if (isValid) continue; + if (allWarnings.length) { + // eslint-disable-next-line no-console + console.warn( + `toMatchDataModelSchema: permissive enum violations (entity will be accepted): ${allWarnings.join(', ')}`, + ); + } + if (allErrors.length) { return { message: () => - `Error validating object with data model schema: ${errors?.map((e) => `${e.schemaId}:${String(e.property)}:${e.message}`).join(', ')}`, + `Error validating object with data model schema: ${allErrors.join(', ')}`, pass: false, }; } From ccbd1c63ca767eb4a9b1c734dd2bea5253937b37 Mon Sep 17 00:00:00 2001 From: James Mountifield Date: Wed, 29 Apr 2026 13:46:33 +0100 Subject: [PATCH 2/3] docs(validator,testing): JSDoc for permissive-enum warning contract Document the EntityValidator class-level contract (additive warnings, default permissive set, opt-out via []) and the toMatchDataModelSchema matcher behavior (console.warn passthrough, jest.spyOn guidance) so callers can discover the surface from IDE hover without reading the test suite. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/validator.ts | 27 +++++++++++++++++++ packages/integration-sdk-testing/src/jest.ts | 23 ++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/integration-sdk-entity-validator/src/validator.ts b/packages/integration-sdk-entity-validator/src/validator.ts index b94111e79..c499514e3 100644 --- a/packages/integration-sdk-entity-validator/src/validator.ts +++ b/packages/integration-sdk-entity-validator/src/validator.ts @@ -63,6 +63,33 @@ const convertUnknownErrorToEntityValidationError = ( }; }; +/** + * Validates entities against AJV-compiled JSON schemas keyed by `_type` and + * `_class`. Returns `{ isValid, errors, warnings, skippedSchemas }`. + * + * Enum violations on properties listed in `permissiveEnumProperties` (default: + * {@link DEFAULT_PERMISSIVE_ENUM_PROPERTIES}) are routed to `warnings` and do + * **not** flip `isValid` to `false`. Every other violation — including type + * mismatches on those same properties, missing required fields, and enum + * violations on properties outside the permissive set — still hard-fails. + * + * The `warnings` field is purely additive: callers that previously inspected + * only `isValid`/`errors` continue to behave identically. To opt out of + * permissive behavior entirely, pass `permissiveEnumProperties: []`. + * + * @example + * ```ts + * const validator = new EntityValidator({ schemas }); + * + * // Unknown NHI subtype → warning, entity accepted. + * validator.validateEntity({ _type: 'svc_account', _class: 'User', _nhiType: 'novel_kind' }); + * // → { isValid: true, errors: null, warnings: [{ property: '_nhiType', ... }], ... } + * + * // Type mismatch on the same property → hard error. + * validator.validateEntity({ _type: 'svc_account', _class: 'User', _nhiType: 42 }); + * // → { isValid: false, errors: [{ property: '_nhiType', validation: 'type', ... }], ... } + * ``` + */ export class EntityValidator { private ajvInstance: Ajv; private permissiveEnumProperties: ReadonlySet; diff --git a/packages/integration-sdk-testing/src/jest.ts b/packages/integration-sdk-testing/src/jest.ts index 87c57f743..c5cd598f9 100644 --- a/packages/integration-sdk-testing/src/jest.ts +++ b/packages/integration-sdk-testing/src/jest.ts @@ -77,6 +77,16 @@ declare global { * }); * * expect(collectedEntities).toMatchDataModelSchema(USER_ENTITY); + * ``` + * + * Permissive enum violations on `_nhiType`, `_nhiOwnerStatus`, and + * `_aiConfidence` (the defaults configured on the underlying + * `EntityValidator`) are emitted via a single `console.warn` and do **not** + * fail the assertion. Hard errors — type mismatches, missing required + * fields, and enum violations on any other property — fail as before. + * + * To assert on the warning in tests, spy on it: + * `jest.spyOn(console, 'warn').mockImplementation(() => {})`. */ toMatchDataModelSchema(metadata: StepEntityMetadata): R; @@ -188,6 +198,19 @@ declare global { } } +/** + * Jest matcher implementation that delegates to `EntityValidator` to validate + * one or more entities against the schema declared on `StepEntityMetadata`. + * + * Permissive enum violations (default: `_nhiType`, `_nhiOwnerStatus`, + * `_aiConfidence`) are surfaced via a single `console.warn` per assertion and + * the matcher still passes. Hard errors (type mismatches, missing required + * fields, enum violations on non-permissive properties) cause the matcher to + * fail with the aggregated error list. + * + * Tests that want to assert on the warning should + * `jest.spyOn(console, 'warn')` before invoking the matcher. + */ export function toMatchDataModelSchema( received: T | T[], metadata: StepEntityMetadata, From cc07122349e2ee0b4379deef1fe0d7a34e49b75b Mon Sep 17 00:00:00 2001 From: James Mountifield Date: Wed, 29 Apr 2026 17:29:16 +0100 Subject: [PATCH 3/3] refactor: drop underscore prefix from NHI metadata properties Tracks data-model PR #104 review: NHI properties (nhiType, isAi, aiConfidence, aiPlatform, nhiOwnerStatus) no longer use the underscore prefix, since underscore is conventionally reserved for system-managed fields (_class, _type, _key, _rawData) and domain metadata follows bare-name conventions elsewhere (User.username, AccessKey.fingerprint). Updates DEFAULT_PERMISSIVE_ENUM_PROPERTIES, JSDoc @example, and all test fixtures + assertions to use the renamed property names. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/__tests__/validator.test.ts | 66 +++++++++---------- .../src/validator.ts | 14 ++-- .../src/__tests__/jest.test.ts | 14 ++-- packages/integration-sdk-testing/src/jest.ts | 8 +-- 4 files changed, 51 insertions(+), 51 deletions(-) diff --git a/packages/integration-sdk-entity-validator/src/__tests__/validator.test.ts b/packages/integration-sdk-entity-validator/src/__tests__/validator.test.ts index ae68f82de..380339a46 100644 --- a/packages/integration-sdk-entity-validator/src/__tests__/validator.test.ts +++ b/packages/integration-sdk-entity-validator/src/__tests__/validator.test.ts @@ -377,8 +377,8 @@ describe('validator', () => { describe('validator — permissive enum properties', () => { // In-memory NHI-shaped schema. The shape mirrors the data-model's NHI class - // additions in M001/S02: `_nhiType`, `_nhiOwnerStatus`, `_aiConfidence` are - // enum-constrained; `_isAi` is a bare boolean (type, not enum). + // additions in M001/S02: `nhiType`, `nhiOwnerStatus`, `aiConfidence` are + // enum-constrained; `isAi` is a bare boolean (type, not enum). const NHI_SCHEMA = { $schema: 'http://json-schema.org/draft-07/schema#', $id: '#NHI', @@ -396,19 +396,19 @@ describe('validator — permissive enum properties', () => { ], }, _type: { type: 'string', minLength: 3 }, - _nhiType: { + nhiType: { type: 'string', enum: ['service_account', 'api_key', 'workload_identity'], }, - _nhiOwnerStatus: { + nhiOwnerStatus: { type: 'string', enum: ['active', 'inactive', 'orphaned'], }, - _aiConfidence: { + aiConfidence: { type: 'string', enum: ['high', 'medium', 'low'], }, - _isAi: { type: 'boolean' }, + isAi: { type: 'boolean' }, }, required: ['_key', '_class', '_type'], }; @@ -417,10 +417,10 @@ describe('validator — permissive enum properties', () => { _class: ['NHI'], _type: 'NHI', _key: '0123456789', - _nhiType: 'service_account', - _nhiOwnerStatus: 'active', - _aiConfidence: 'high', - _isAi: true, + nhiType: 'service_account', + nhiOwnerStatus: 'active', + aiConfidence: 'high', + isAi: true, }; test('valid NHI entity produces no errors and no warnings', () => { @@ -435,63 +435,63 @@ describe('validator — permissive enum properties', () => { }); }); - test('unknown _nhiType becomes a warning, not an error', () => { + test('unknown nhiType becomes a warning, not an error', () => { const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); const result = validator.validateEntity({ ...validNhiEntity, - _nhiType: 'unknown_subtype', + nhiType: 'unknown_subtype', }); expect(result.isValid).toBe(true); expect(result.errors).toBeNull(); expect(result.warnings).toEqual([ { schemaId: '#NHI', - property: '_nhiType', + property: 'nhiType', message: expect.stringContaining('must be equal to one of'), validation: 'enum', }, ]); }); - test('unknown _nhiOwnerStatus becomes a warning', () => { + test('unknown nhiOwnerStatus becomes a warning', () => { const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); const result = validator.validateEntity({ ...validNhiEntity, - _nhiOwnerStatus: 'mystery', + nhiOwnerStatus: 'mystery', }); expect(result.isValid).toBe(true); expect(result.errors).toBeNull(); expect(result.warnings).toHaveLength(1); - expect(result.warnings![0].property).toBe('_nhiOwnerStatus'); + expect(result.warnings![0].property).toBe('nhiOwnerStatus'); expect(result.warnings![0].validation).toBe('enum'); }); - test('unknown _aiConfidence becomes a warning', () => { + test('unknown aiConfidence becomes a warning', () => { const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); const result = validator.validateEntity({ ...validNhiEntity, - _aiConfidence: 'extremely high', + aiConfidence: 'extremely high', }); expect(result.isValid).toBe(true); expect(result.warnings).toHaveLength(1); - expect(result.warnings![0].property).toBe('_aiConfidence'); + expect(result.warnings![0].property).toBe('aiConfidence'); }); test('type mismatch on a permissive property still hard-errors', () => { - // _isAi: 'yes' is a string where boolean is required. The property is not + // isAi: 'yes' is a string where boolean is required. The property is not // in the permissive set anyway, but the point is: keyword === 'type' is // not 'enum', so even a permissive property would still hard-fail here. const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); const result = validator.validateEntity({ ...validNhiEntity, - _isAi: 'yes', + isAi: 'yes', }); expect(result.isValid).toBe(false); expect(result.warnings).toBeNull(); expect(result.errors).toEqual([ { schemaId: '#NHI', - property: '_isAi', + property: 'isAi', message: expect.stringContaining('must be boolean'), validation: 'type', }, @@ -499,18 +499,18 @@ describe('validator — permissive enum properties', () => { }); test('type mismatch on a permissive enum property is still an error', () => { - // _nhiType is in the permissive set, but a number-shaped value triggers + // nhiType is in the permissive set, but a number-shaped value triggers // keyword === 'type' (not 'enum'), so it must hard-error. const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); const result = validator.validateEntity({ ...validNhiEntity, - _nhiType: 42, + nhiType: 42, }); expect(result.isValid).toBe(false); expect(result.errors).toEqual([ { schemaId: '#NHI', - property: '_nhiType', + property: 'nhiType', message: expect.stringContaining('must be string'), validation: 'type', }, @@ -522,7 +522,7 @@ describe('validator — permissive enum properties', () => { const { _key, ...entityWithoutKey } = validNhiEntity; const result = validator.validateEntity({ ...entityWithoutKey, - _nhiType: 'unknown_subtype', + nhiType: 'unknown_subtype', }); expect(result.isValid).toBe(false); expect(result.errors).toEqual([ @@ -536,7 +536,7 @@ describe('validator — permissive enum properties', () => { expect(result.warnings).toEqual([ { schemaId: '#NHI', - property: '_nhiType', + property: 'nhiType', message: expect.stringContaining('must be equal to one of'), validation: 'enum', }, @@ -550,14 +550,14 @@ describe('validator — permissive enum properties', () => { }); const result = validator.validateEntity({ ...validNhiEntity, - _nhiType: 'unknown_subtype', + nhiType: 'unknown_subtype', }); expect(result.isValid).toBe(false); expect(result.warnings).toBeNull(); expect(result.errors).toEqual([ { schemaId: '#NHI', - property: '_nhiType', + property: 'nhiType', message: expect.stringContaining('must be equal to one of'), validation: 'enum', }, @@ -567,16 +567,16 @@ describe('validator — permissive enum properties', () => { test('permissiveEnumProperties can be extended with additional properties', () => { const validator = new EntityValidator({ schemas: [NHI_SCHEMA], - permissiveEnumProperties: ['_nhiType', '_aiConfidence', '_customField'], + permissiveEnumProperties: ['nhiType', 'aiConfidence', 'customField'], }); - // _nhiOwnerStatus is no longer permissive in this configuration. + // nhiOwnerStatus is no longer permissive in this configuration. const result = validator.validateEntity({ ...validNhiEntity, - _nhiOwnerStatus: 'mystery', + nhiOwnerStatus: 'mystery', }); expect(result.isValid).toBe(false); expect(result.errors).toHaveLength(1); - expect(result.errors![0].property).toBe('_nhiOwnerStatus'); + expect(result.errors![0].property).toBe('nhiOwnerStatus'); expect(result.errors![0].validation).toBe('enum'); }); }); diff --git a/packages/integration-sdk-entity-validator/src/validator.ts b/packages/integration-sdk-entity-validator/src/validator.ts index c499514e3..0abbedc6e 100644 --- a/packages/integration-sdk-entity-validator/src/validator.ts +++ b/packages/integration-sdk-entity-validator/src/validator.ts @@ -34,9 +34,9 @@ type ValidateEntityOptions = { * properties still hard-fail. */ export const DEFAULT_PERMISSIVE_ENUM_PROPERTIES: readonly string[] = [ - '_nhiType', - '_nhiOwnerStatus', - '_aiConfidence', + 'nhiType', + 'nhiOwnerStatus', + 'aiConfidence', ]; const convertUnknownErrorToEntityValidationError = ( @@ -82,12 +82,12 @@ const convertUnknownErrorToEntityValidationError = ( * const validator = new EntityValidator({ schemas }); * * // Unknown NHI subtype → warning, entity accepted. - * validator.validateEntity({ _type: 'svc_account', _class: 'User', _nhiType: 'novel_kind' }); - * // → { isValid: true, errors: null, warnings: [{ property: '_nhiType', ... }], ... } + * validator.validateEntity({ _type: 'svc_account', _class: 'User', nhiType: 'novel_kind' }); + * // → { isValid: true, errors: null, warnings: [{ property: 'nhiType', ... }], ... } * * // Type mismatch on the same property → hard error. - * validator.validateEntity({ _type: 'svc_account', _class: 'User', _nhiType: 42 }); - * // → { isValid: false, errors: [{ property: '_nhiType', validation: 'type', ... }], ... } + * validator.validateEntity({ _type: 'svc_account', _class: 'User', nhiType: 42 }); + * // → { isValid: false, errors: [{ property: 'nhiType', validation: 'type', ... }], ... } * ``` */ export class EntityValidator { diff --git a/packages/integration-sdk-testing/src/__tests__/jest.test.ts b/packages/integration-sdk-testing/src/__tests__/jest.test.ts index 916bd6b55..324caf4d1 100644 --- a/packages/integration-sdk-testing/src/__tests__/jest.test.ts +++ b/packages/integration-sdk-testing/src/__tests__/jest.test.ts @@ -653,7 +653,7 @@ describe('#toMatchDataModelSchema', () => { expect(result.pass).toBe(false); }); - test('should pass and emit a console.warn when entity violates a permissive enum (_aiConfidence)', () => { + test('should pass and emit a console.warn when entity violates a permissive enum (aiConfidence)', () => { const StringEnum = (values: [...T]) => SchemaType.Unsafe({ type: 'string', @@ -665,7 +665,7 @@ describe('#toMatchDataModelSchema', () => { _type: 'permissive_enum_warning_test_pass', description: 'Schema with a permissive enum property.', schema: SchemaType.Object({ - _aiConfidence: StringEnum(['high', 'medium', 'low']), + aiConfidence: StringEnum(['high', 'medium', 'low']), }), }); @@ -675,7 +675,7 @@ describe('#toMatchDataModelSchema', () => { _key: 'permissive-warn-pass-1', displayName: 'Thing', category: ['software'], - _aiConfidence: 'extreme' as unknown as 'high' | 'medium' | 'low', + aiConfidence: 'extreme' as unknown as 'high' | 'medium' | 'low', }); const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); @@ -684,7 +684,7 @@ describe('#toMatchDataModelSchema', () => { expect(result.pass).toBe(true); expect(warnSpy).toHaveBeenCalledTimes(1); const warnMessage = warnSpy.mock.calls[0][0] as string; - expect(warnMessage).toContain('_aiConfidence'); + expect(warnMessage).toContain('aiConfidence'); expect(warnMessage).toContain('permissive enum violations'); } finally { warnSpy.mockRestore(); @@ -704,7 +704,7 @@ describe('#toMatchDataModelSchema', () => { description: 'Schema with both a strict enum and a permissive enum.', schema: SchemaType.Object({ state: StringEnum(['ENABLED', 'DISABLED']), - _aiConfidence: StringEnum(['high', 'medium', 'low']), + aiConfidence: StringEnum(['high', 'medium', 'low']), }), }); @@ -715,7 +715,7 @@ describe('#toMatchDataModelSchema', () => { displayName: 'Thing', category: ['software'], state: 'BOGUS' as unknown as 'ENABLED' | 'DISABLED', - _aiConfidence: 'extreme' as unknown as 'high' | 'medium' | 'low', + aiConfidence: 'extreme' as unknown as 'high' | 'medium' | 'low', }); const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); @@ -726,7 +726,7 @@ describe('#toMatchDataModelSchema', () => { expect(message).toContain('state'); // warning is still surfaced even though we fail expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toContain('_aiConfidence'); + expect(warnSpy.mock.calls[0][0]).toContain('aiConfidence'); } finally { warnSpy.mockRestore(); } diff --git a/packages/integration-sdk-testing/src/jest.ts b/packages/integration-sdk-testing/src/jest.ts index c5cd598f9..0388d8e06 100644 --- a/packages/integration-sdk-testing/src/jest.ts +++ b/packages/integration-sdk-testing/src/jest.ts @@ -79,8 +79,8 @@ declare global { * expect(collectedEntities).toMatchDataModelSchema(USER_ENTITY); * ``` * - * Permissive enum violations on `_nhiType`, `_nhiOwnerStatus`, and - * `_aiConfidence` (the defaults configured on the underlying + * Permissive enum violations on `nhiType`, `nhiOwnerStatus`, and + * `aiConfidence` (the defaults configured on the underlying * `EntityValidator`) are emitted via a single `console.warn` and do **not** * fail the assertion. Hard errors — type mismatches, missing required * fields, and enum violations on any other property — fail as before. @@ -202,8 +202,8 @@ declare global { * Jest matcher implementation that delegates to `EntityValidator` to validate * one or more entities against the schema declared on `StepEntityMetadata`. * - * Permissive enum violations (default: `_nhiType`, `_nhiOwnerStatus`, - * `_aiConfidence`) are surfaced via a single `console.warn` per assertion and + * Permissive enum violations (default: `nhiType`, `nhiOwnerStatus`, + * `aiConfidence`) are surfaced via a single `console.warn` per assertion and * the matcher still passes. Hard errors (type mismatches, missing required * fields, enum violations on non-permissive properties) cause the matcher to * fail with the aggregated error list.