Repository: stoplightio/spectral
Pull request #575 (2019-09-19 20:21:37)

feat: introduce json-schema format
Closes https://github.com/stoplightio/spectral/issues/571

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No

**Additional context**

<will add some>

marbemac: Looks great, nice work. These lookup / format checkers are quite helpful from a library user perspective (and we could probably use them in studio) - should we export a portion of that stuff from the root package?
P0lip: @marbemac 
Yeah, that's a valid point. Done.
Right now, I simply use full paths, like `@stoplight/spectral/dist/lookups/openapi`, but this will beak anytime we decide to move the file.

Patch:
diff --git a/docs/getting-started/rulesets.md b/docs/getting-started/rulesets.md
index 886167ed..873dfac3 100644
--- a/docs/getting-started/rulesets.md
+++ b/docs/getting-started/rulesets.md
@@ -65,7 +65,13 @@ Formats are an optional way to specify which API description formats a rule, or
 
 - `oas2` (this is OpenAPI v2.0 - formerly known as Swagger)
 - `oas3` (this is OpenAPI v3.0)
-
+- `json-schema` (this is JSON Schema, detection based on the value of $schema property)
+- `json-schema-loose` (this is JSON Schema, loose check, no $schema required)
+- `json-schema-draft4` (this is JSON Schema Draft 4, detection based on the value of $schema property)
+- `json-schema-draft6` (this is JSON Schema Draft 6, detection based on the value of $schema property)
+- `json-schema-draft7` (this is JSON Schema Draft 7, detection based on the value of $schema property)
+- `json-schema-2019-09` (this is JSON Schema 2019-09, detection based on the value of $schema property)
+  
 Specifying the format is optional, so you can completely ignore this if all the rules you are writing apply to any document you lint, or if you have specific rulesets for different formats. If you'd like to use one ruleset for multiple formats, the formats key is here to help.
 
 ```yaml
diff --git a/src/cli/services/linter.ts b/src/cli/services/linter.ts
index 482e9428..0c38d141 100644
--- a/src/cli/services/linter.ts
+++ b/src/cli/services/linter.ts
@@ -2,13 +2,22 @@ import { isAbsolute, resolve } from '@stoplight/path';
 import { IParserResult, Optional } from '@stoplight/types';
 import { getLocationForJsonPath, parseWithPointers } from '@stoplight/yaml';
 
+import {
+  isJSONSchema,
+  isJSONSchemaDraft2019_09,
+  isJSONSchemaDraft4,
+  isJSONSchemaDraft6,
+  isJSONSchemaDraft7,
+  isJSONSchemaLoose,
+  isOpenApiv2,
+  isOpenApiv3,
+} from '../../formats';
 import { readParsable } from '../../fs/reader';
 import { httpAndFileResolver } from '../../resolvers/http-and-file';
 import { getDefaultRulesetFile } from '../../rulesets/loader';
-import { isOpenApiv2, isOpenApiv3 } from '../../rulesets/lookups';
 import { readRuleset } from '../../rulesets/reader';
 import { Spectral } from '../../spectral';
-import { IParsedResult, RuleCollection } from '../../types';
+import { FormatLookup, IParsedResult, RuleCollection } from '../../types';
 import { ILintConfig } from '../../types/config';
 import { IRuleset } from '../../types/ruleset';
 
@@ -23,6 +32,17 @@ export async function loadRulesets(cwd: string, rulesetFiles: string[]): Promise
   return readRuleset(rulesetFiles.map(file => (isAbsolute(file) ? file : resolve(cwd, file))));
 }
 
+const KNOWN_FORMATS: Array<[string, FormatLookup, string]> = [
+  ['oas2', isOpenApiv2, 'OpenAPI 2.0 (Swagger) detected'],
+  ['oas3', isOpenApiv3, 'OpenAPI 3.x detected'],
+  ['json-schema', isJSONSchema, 'JSON Schema detected'],
+  ['json-schema-loose', isJSONSchemaLoose, 'JSON Schema (loose) detected'],
+  ['json-schema-draft4', isJSONSchemaDraft4, 'JSON Schema Draft 4 detected'],
+  ['json-schema-draft6', isJSONSchemaDraft6, 'JSON Schema Draft 6 detected'],
+  ['json-schema-draft7', isJSONSchemaDraft7, 'JSON Schema Draft 7 detected'],
+  ['json-schema-2019-09', isJSONSchemaDraft2019_09, 'JSON Schema Draft 2019-09 detected'],
+];
+
 export async function lint(name: string, flags: ILintConfig, rulesetFile: Optional<string[]>) {
   if (flags.verbose) {
     console.info(`Linting ${name}`);
@@ -50,27 +70,18 @@ export async function lint(name: string, flags: ILintConfig, rulesetFile: Option
     }
   }
 
-  spectral.registerFormat('oas2', document => {
-    if (isOpenApiv2(document)) {
-      if (!flags.quiet) {
-        console.log('OpenAPI 2.0 (Swagger) detected');
-      }
-
-      return true;
-    }
-    return false;
-  });
+  for (const [format, lookup, message] of KNOWN_FORMATS) {
+    spectral.registerFormat(format, document => {
+      if (lookup(document)) {
+        if (!flags.quiet) {
+          console.log(message);
+        }
 
-  spectral.registerFormat('oas3', document => {
-    if (isOpenApiv3(document)) {
-      if (!flags.quiet) {
-        console.log('OpenAPI 3.x detected');
+        return true;
       }
-
-      return true;
-    }
-    return false;
-  });
+      return false;
+    });
+  }
 
   spectral.setRuleset(ruleset);
 
diff --git a/src/formats/index.ts b/src/formats/index.ts
new file mode 100644
index 00000000..54f1929e
--- /dev/null
+++ b/src/formats/index.ts
@@ -0,0 +1,2 @@
+export * from './openapi';
+export * from './json-schema';
diff --git a/src/formats/json-schema.ts b/src/formats/json-schema.ts
new file mode 100644
index 00000000..5d75a25f
--- /dev/null
+++ b/src/formats/json-schema.ts
@@ -0,0 +1,41 @@
+import { JSONSchema4, JSONSchema6, JSONSchema7 } from 'json-schema';
+import { isObject } from '../utils/isObject';
+
+type JSONSchema = JSONSchema4 | JSONSchema6 | JSONSchema7;
+
+const KNOWN_JSON_SCHEMA_TYPES = ['array', 'boolean', 'integer', 'null', 'number', 'object', 'string'];
+const KNOWN_JSON_SCHEMA_COMBINERS = ['allOf', 'oneOf', 'anyOf'];
+const $SCHEMA_DRAFT4_REGEX = /^https?:\/\/json-schema.org\/draft-04\/(?:hyper-)?schema#?$/;
+const $SCHEMA_DRAFT6_REGEX = /^https?:\/\/json-schema.org\/draft-06\/(?:hyper-)?schema#?$/;
+const $SCHEMA_DRAFT7_REGEX = /^https?:\/\/json-schema.org\/draft-07\/(?:hyper-)?schema#?$/;
+const $SCHEMA_DRAFT_2019_09_REGEX = /^https?:\/\/json-schema.org\/draft\/2019-09\/(?:hyper-)?schema#?$/;
+
+const hasValidJSONSchemaType = (document: object | Partial<{ type: unknown }>) =>
+  'type' in document && typeof document.type === 'string' && KNOWN_JSON_SCHEMA_TYPES.includes(document.type);
+
+const hasValidJSONSchemaCombiner = (document: object) =>
+  KNOWN_JSON_SCHEMA_COMBINERS.some(combiner => combiner in document && isObject(document[combiner]));
+
+export const isJSONSchema = (document: unknown): document is JSONSchema & { $schema: string } =>
+  isObject(document) &&
+  '$schema' in document &&
+  String((document as Partial<Pick<JSONSchema, '$schema'>>).$schema).includes('//json-schema.org/');
+
+export const isJSONSchemaLoose = (document: unknown) =>
+  isObject(document) &&
+  (isJSONSchema(document) ||
+    hasValidJSONSchemaType(document) ||
+    hasValidJSONSchemaCombiner(document) ||
+    'not' in document);
+
+export const isJSONSchemaDraft4 = (document: unknown) =>
+  isJSONSchema(document) && $SCHEMA_DRAFT4_REGEX.test(document.$schema);
+
+export const isJSONSchemaDraft6 = (document: unknown) =>
+  isJSONSchema(document) && $SCHEMA_DRAFT6_REGEX.test(document.$schema);
+
+export const isJSONSchemaDraft7 = (document: unknown) =>
+  isJSONSchema(document) && $SCHEMA_DRAFT7_REGEX.test(document.$schema);
+
+export const isJSONSchemaDraft2019_09 = (document: unknown) =>
+  isJSONSchema(document) && $SCHEMA_DRAFT_2019_09_REGEX.test(document.$schema);
diff --git a/src/rulesets/lookups.ts b/src/formats/openapi.ts
similarity index 85%
rename from src/rulesets/lookups.ts
rename to src/formats/openapi.ts
index ac166e4e..9f5c7c23 100644
--- a/src/rulesets/lookups.ts
+++ b/src/formats/openapi.ts
@@ -1,4 +1,4 @@
-const isObject = (thing: unknown): thing is object => thing !== null && typeof thing === 'object';
+import { isObject } from '../utils/isObject';
 
 type MaybeOAS2 = Partial<{ swagger: unknown }>;
 type MaybeOAS3 = Partial<{ openapi: unknown }>;
diff --git a/src/index.ts b/src/index.ts
index 9e3cb6c4..6463d20d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,1 +1,2 @@
 export * from './spectral';
+export * from './formats';
diff --git a/src/rulesets/evaluators.ts b/src/rulesets/evaluators.ts
index 275561b8..5b395b5d 100644
--- a/src/rulesets/evaluators.ts
+++ b/src/rulesets/evaluators.ts
@@ -1,6 +1,7 @@
 import { Optional } from '@stoplight/types';
 import { JSONSchema7 } from 'json-schema';
 import { IFunction } from '../types';
+import { isObject } from '../utils/isObject';
 import { decorateIFunctionWithSchemaValidation } from './validation';
 
 export type CJSExport = Partial<{ exports: object | ESCJSCompatibleExport }>;
@@ -28,8 +29,6 @@ const createDefine = (exports: CJSExport) => {
   return define;
 };
 
-const isObject = (thing: unknown): thing is object => thing !== null && typeof thing === 'object';
-
 const isESCJSCompatibleExport = (obj: unknown): obj is ESCJSCompatibleExport => isObject(obj) && 'default' in obj;
 
 // note: this code is hand-crafted and cover cases we want to support
diff --git a/src/utils/isObject.ts b/src/utils/isObject.ts
new file mode 100644
index 00000000..d76de650
--- /dev/null
+++ b/src/utils/isObject.ts
@@ -0,0 +1,1 @@
+export const isObject = (thing: unknown): thing is object => thing !== null && typeof thing === 'object';


Test patch:
diff --git a/src/__tests__/linter.jest.test.ts b/src/__tests__/linter.jest.test.ts
index 60cd9fae..e4721b83 100644
--- a/src/__tests__/linter.jest.test.ts
+++ b/src/__tests__/linter.jest.test.ts
@@ -1,5 +1,5 @@
 import * as path from '@stoplight/path';
-import { isOpenApiv2, isOpenApiv3 } from '../rulesets/lookups';
+import { isOpenApiv2, isOpenApiv3 } from '../formats';
 import { Spectral } from '../spectral';
 
 const customFunctionOASRuleset = path.join(__dirname, './__fixtures__/custom-functions-oas-ruleset.json');
diff --git a/src/__tests__/linter.test.ts b/src/__tests__/linter.test.ts
index b7b5a696..68412ee7 100644
--- a/src/__tests__/linter.test.ts
+++ b/src/__tests__/linter.test.ts
@@ -2,8 +2,8 @@ import { getLocationForJsonPath, parseWithPointers } from '@stoplight/json';
 import { Resolver } from '@stoplight/json-ref-resolver';
 import { DiagnosticSeverity } from '@stoplight/types';
 import { parse } from '@stoplight/yaml';
+import { isOpenApiv2, isOpenApiv3 } from '../formats';
 import { mergeRules, readRuleset } from '../rulesets';
-import { isOpenApiv2, isOpenApiv3 } from '../rulesets/lookups';
 import { RuleCollection, Spectral } from '../spectral';
 
 const invalidSchema = JSON.stringify(require('./__fixtures__/petstore.invalid-schema.oas3.json'));
diff --git a/src/__tests__/spectral.jest.test.ts b/src/__tests__/spectral.jest.test.ts
index 0c2bef31..73bc3e8c 100644
--- a/src/__tests__/spectral.jest.test.ts
+++ b/src/__tests__/spectral.jest.test.ts
@@ -3,8 +3,8 @@ import { DiagnosticSeverity, Dictionary } from '@stoplight/types';
 import * as fs from 'fs';
 import * as nock from 'nock';
 import * as path from 'path';
+import { isOpenApiv2 } from '../formats';
 import { httpAndFileResolver } from '../resolvers/http-and-file';
-import { isOpenApiv2 } from '../rulesets/lookups';
 import { Spectral } from '../spectral';
 
 const oasRuleset = require('../rulesets/oas/index.json');
diff --git a/src/formats/__tests__/json-schema.spec.ts b/src/formats/__tests__/json-schema.spec.ts
new file mode 100644
index 00000000..04a5bdaf
--- /dev/null
+++ b/src/formats/__tests__/json-schema.spec.ts
@@ -0,0 +1,234 @@
+import {
+  isJSONSchema,
+  isJSONSchemaDraft2019_09,
+  isJSONSchemaDraft4,
+  isJSONSchemaDraft6,
+  isJSONSchemaDraft7,
+  isJSONSchemaLoose,
+} from '../json-schema';
+
+describe('JSON Schema format', () => {
+  describe('JSON Schema strict', () => {
+    it.each([
+      'http://json-schema.org/schema#',
+      'https://json-schema.org/schema#',
+      'http://json-schema.org/hyper-schema#',
+      'http://json-schema.org/draft-04/schema#',
+      'http://json-schema.org/draft-04/hyper-schema#',
+      'http://json-schema.org/draft-06/schema#',
+      'http://json-schema.org/draft-06/hyper-schema#',
+    ])('recognizes %s schema correctly', $schema => {
+      expect(isJSONSchema({ $schema })).toBe(true);
+    });
+
+    it('does not recognize invalid document', () => {
+      expect(isJSONSchema({ $schema: '2.0' })).toBe(false);
+      expect(isJSONSchema({ $schema: 'json-schema' })).toBe(false);
+      expect(isJSONSchema({ $schema: 2 })).toBe(false);
+      expect(isJSONSchema({ swagger: null })).toBe(false);
+      expect(isJSONSchema({ allOf: [] })).toBe(false);
+      expect(isJSONSchema({ type: 'string' })).toBe(false);
+      expect(isJSONSchema({})).toBe(false);
+      expect(isJSONSchema(null)).toBe(false);
+    });
+  });
+
+  describe('JSON Schema loose', () => {
+    describe('by $schema', () => {
+      it.each([
+        'http://json-schema.org/schema#',
+        'https://json-schema.org/schema#',
+        'http://json-schema.org/hyper-schema#',
+        'http://json-schema.org/draft-04/schema#',
+        'http://json-schema.org/draft-04/hyper-schema#',
+        'http://json-schema.org/draft-06/schema#',
+        'http://json-schema.org/draft-06/hyper-schema#',
+      ])('recognizes %s schema correctly', $schema => {
+        expect(isJSONSchemaLoose({ $schema })).toBe(true);
+      });
+    });
+
+    describe('by type', () => {
+      it.each(['array', 'boolean', 'integer', 'null', 'number', 'object', 'string'])(
+        'recognizes %s type correctly',
+        type => {
+          expect(isJSONSchemaLoose({ type })).toBe(true);
+        },
+      );
+
+      it.each(['foo', 'bar', 1, 2, void 0, null])('does not recognize invalid %s type', type => {
+        expect(isJSONSchemaLoose({ type })).toBe(false);
+      });
+    });
+
+    describe('by combiner', () => {
+      it.each(['allOf', 'oneOf', 'anyOf'])('recognizes %s combiner correctly', combiner => {
+        expect(isJSONSchemaLoose({ [combiner]: [] })).toBe(true);
+      });
+
+      it.each(['allOf', 'oneOf', 'anyOf'])('does not %s combiner that is not an object', combiner => {
+        expect(isJSONSchemaLoose({ [combiner]: void 0 })).toBe(false);
+        expect(isJSONSchemaLoose({ [combiner]: 0 })).toBe(false);
+        expect(isJSONSchemaLoose({ [combiner]: '' })).toBe(false);
+        expect(isJSONSchemaLoose({ [combiner]: null })).toBe(false);
+      });
+    });
+
+    it('recognizes by the presence of "not"', () => {
+      expect(isJSONSchemaLoose({ not: {} })).toBe(true);
+    });
+
+    describe('mixed', () => {
+      it('invalid type but valid combiner', () => {
+        expect(isJSONSchemaLoose({ type: 'foo', allOf: [] })).toBe(true);
+      });
+
+      it('valid type but invalid combiner', () => {
+        expect(isJSONSchemaLoose({ type: 'string', allOf: null })).toBe(true);
+      });
+    });
+  });
+
+  describe('JSON Schema Draft 4', () => {
+    it.each([
+      'http://json-schema.org/draft-04/schema#',
+      'https://json-schema.org/draft-04/schema#',
+      'http://json-schema.org/draft-04/schema',
+      'https://json-schema.org/draft-04/schema',
+      'http://json-schema.org/draft-04/hyper-schema#',
+      'https://json-schema.org/draft-04/hyper-schema#',
+      'http://json-schema.org/draft-04/hyper-schema',
+      'https://json-schema.org/draft-04/hyper-schema',
+    ])('recognizes %s schema correctly', $schema => {
+      expect(isJSONSchemaDraft4({ $schema })).toBe(true);
+    });
+
+    it.each([
+      'http://json-schema.org/schema#',
+      'https://json-schema.org/schema#',
+      'http://json-schema.org/hyper-schema#',
+      'http://json-schema.org/draft-06/schema#',
+      'http://json-schema.org/draft-06/hyper-schema#',
+    ])('does not recognize %s schema', $schema => {
+      expect(isJSONSchemaDraft4({ $schema })).toBe(false);
+    });
+
+    it('does not recognize invalid document', () => {
+      expect(isJSONSchemaDraft4({ $schema: '2.0' })).toBe(false);
+      expect(isJSONSchemaDraft4({ $schema: 'json-schema' })).toBe(false);
+      expect(isJSONSchemaDraft4({ $schema: 2 })).toBe(false);
+      expect(isJSONSchemaDraft4({ swagger: null })).toBe(false);
+      expect(isJSONSchemaDraft4({ allOf: [] })).toBe(false);
+      expect(isJSONSchemaDraft4({ type: 'string' })).toBe(false);
+      expect(isJSONSchemaDraft4({})).toBe(false);
+      expect(isJSONSchemaDraft4(null)).toBe(false);
+    });
+  });
+  describe('JSON Schema Draft 6', () => {
+    it.each([
+      'http://json-schema.org/draft-06/schema#',
+      'https://json-schema.org/draft-06/schema#',
+      'http://json-schema.org/draft-06/schema',
+      'https://json-schema.org/draft-06/schema',
+      'http://json-schema.org/draft-06/hyper-schema#',
+      'https://json-schema.org/draft-06/hyper-schema#',
+      'http://json-schema.org/draft-06/hyper-schema',
+      'https://json-schema.org/draft-06/hyper-schema',
+    ])('recognizes %s schema correctly', $schema => {
+      expect(isJSONSchemaDraft6({ $schema })).toBe(true);
+    });
+
+    it.each([
+      'http://json-schema.org/schema#',
+      'https://json-schema.org/schema#',
+      'http://json-schema.org/hyper-schema#',
+      'http://json-schema.org/draft-04/schema#',
+      'http://json-schema.org/draft-04/hyper-schema#',
+    ])('does not recognize %s schema', $schema => {
+      expect(isJSONSchemaDraft6({ $schema })).toBe(false);
+    });
+
+    it('does not recognize invalid document', () => {
+      expect(isJSONSchemaDraft6({ $schema: '2.0' })).toBe(false);
+      expect(isJSONSchemaDraft6({ $schema: 'json-schema' })).toBe(false);
+      expect(isJSONSchemaDraft6({ $schema: 2 })).toBe(false);
+      expect(isJSONSchemaDraft6({ swagger: null })).toBe(false);
+      expect(isJSONSchemaDraft6({ allOf: [] })).toBe(false);
+      expect(isJSONSchemaDraft6({ type: 'string' })).toBe(false);
+      expect(isJSONSchemaDraft6({})).toBe(false);
+      expect(isJSONSchemaDraft6(null)).toBe(false);
+    });
+  });
+
+  describe('JSON Schema Draft 7', () => {
+    it.each([
+      'http://json-schema.org/draft-07/schema#',
+      'https://json-schema.org/draft-07/schema#',
+      'http://json-schema.org/draft-07/schema',
+      'https://json-schema.org/draft-07/schema',
+      'http://json-schema.org/draft-07/hyper-schema#',
+      'https://json-schema.org/draft-07/hyper-schema#',
+      'http://json-schema.org/draft-07/hyper-schema',
+      'https://json-schema.org/draft-07/hyper-schema',
+    ])('recognizes %s schema correctly', $schema => {
+      expect(isJSONSchemaDraft7({ $schema })).toBe(true);
+    });
+
+    it.each([
+      'http://json-schema.org/schema#',
+      'https://json-schema.org/schema#',
+      'http://json-schema.org/hyper-schema#',
+      'http://json-schema.org/draft-06/schema#',
+      'http://json-schema.org/draft-06/hyper-schema#',
+    ])('does not recognize %s schema', $schema => {
+      expect(isJSONSchemaDraft7({ $schema })).toBe(false);
+    });
+
+    it('does not recognize invalid document', () => {
+      expect(isJSONSchemaDraft7({ $schema: '2.0' })).toBe(false);
+      expect(isJSONSchemaDraft7({ $schema: 'json-schema' })).toBe(false);
+      expect(isJSONSchemaDraft7({ $schema: 2 })).toBe(false);
+      expect(isJSONSchemaDraft7({ swagger: null })).toBe(false);
+      expect(isJSONSchemaDraft7({ allOf: [] })).toBe(false);
+      expect(isJSONSchemaDraft7({ type: 'string' })).toBe(false);
+      expect(isJSONSchemaDraft7({})).toBe(false);
+      expect(isJSONSchemaDraft7(null)).toBe(false);
+    });
+  });
+
+  describe('JSON Schema Draft 2019-09', () => {
+    it.each([
+      'http://json-schema.org/draft/2019-09/schema#',
+      'https://json-schema.org/draft/2019-09/schema#',
+      'http://json-schema.org/draft/2019-09/schema',
+      'https://json-schema.org/draft/2019-09/schema',
+      'http://json-schema.org/draft/2019-09/hyper-schema#',
+      'https://json-schema.org/draft/2019-09/hyper-schema#',
+      'http://json-schema.org/draft/2019-09/hyper-schema',
+      'https://json-schema.org/draft/2019-09/hyper-schema',
+    ])('recognizes %s schema correctly', $schema => {
+      expect(isJSONSchemaDraft2019_09({ $schema })).toBe(true);
+    });
+
+    it.each([
+      'http://json-schema.org/schema#',
+      'https://json-schema.org/schema#',
+      'http://json-schema.org/hyper-schema#',
+      'http://json-schema.org/draft-06/schema#',
+      'http://json-schema.org/draft-06/hyper-schema#',
+    ])('does not recognize %s schema', $schema => {
+      expect(isJSONSchemaDraft2019_09({ $schema })).toBe(false);
+    });
+
+    it('does not recognize invalid document', () => {
+      expect(isJSONSchemaDraft2019_09({ $schema: '2.0' })).toBe(false);
+      expect(isJSONSchemaDraft2019_09({ $schema: 'json-schema' })).toBe(false);
+      expect(isJSONSchemaDraft2019_09({ $schema: 2 })).toBe(false);
+      expect(isJSONSchemaDraft2019_09({ swagger: null })).toBe(false);
+      expect(isJSONSchemaDraft2019_09({ allOf: [] })).toBe(false);
+      expect(isJSONSchemaDraft2019_09({ type: 'string' })).toBe(false);
+      expect(isJSONSchemaDraft2019_09({})).toBe(false);
+      expect(isJSONSchemaDraft2019_09(null)).toBe(false);
+    });
+  });
+});
diff --git a/src/rulesets/__tests__/lookups.spec.ts b/src/formats/__tests__/openapi.spec.ts
similarity index 94%
rename from src/rulesets/__tests__/lookups.spec.ts
rename to src/formats/__tests__/openapi.spec.ts
index 6bf9491c..fb275473 100644
--- a/src/rulesets/__tests__/lookups.spec.ts
+++ b/src/formats/__tests__/openapi.spec.ts
@@ -1,6 +1,6 @@
-import { isOpenApiv2, isOpenApiv3, isOpenApiv3_1 } from '../lookups';
+import { isOpenApiv2, isOpenApiv3, isOpenApiv3_1 } from '../openapi';
 
-describe('Format lookups', () => {
+describe('OpenAPI format', () => {
   describe('OpenAPI 2.0 aka Swagger', () => {
     it.each(['2.0.0', '2', '2.0'])('recognizes %s version correctly', version => {
       expect(isOpenApiv2({ swagger: version })).toBe(true);
diff --git a/test-harness/scenarios/rulesets/loose-schema-ruleset.json b/test-harness/scenarios/rulesets/loose-schema-ruleset.json
new file mode 100644
index 00000000..d527e94f
--- /dev/null
+++ b/test-harness/scenarios/rulesets/loose-schema-ruleset.json
@@ -0,0 +1,27 @@
+{
+  "formats": ["json-schema-loose"],
+  "rules": {
+    "no-root-all-of": {
+      "description": "No root allOf combiner",
+      "message": "{{description}}",
+      "recommended": true,
+      "severity": "error",
+      "type": "validation",
+      "given": "$",
+      "then": {
+        "field": "allOf",
+        "function": "falsy"
+      }
+    },
+    "no-empty-description": {
+      "description": "Description must not be empty",
+      "message": "{{description}}",
+      "recommended": true,
+      "type": "validation",
+      "given": "$..description",
+      "then": {
+        "function": "truthy"
+      }
+    }
+  }
+}
diff --git a/test-harness/scenarios/todo-full-loose-schema.scenario b/test-harness/scenarios/todo-full-loose-schema.scenario
new file mode 100644
index 00000000..d0efd1f2
--- /dev/null
+++ b/test-harness/scenarios/todo-full-loose-schema.scenario
@@ -0,0 +1,67 @@
+====test====
+Loose JSON Schema can be validated
+====document====
+{
+  "title": "Todo Full",
+  "allOf": [
+    {
+      "title": "Todo Partial",
+      "description": "",
+      "type": "object",
+      "properties": {
+        "name": {
+          "type": "string"
+        },
+        "completed": {
+          "type": [
+            "boolean",
+            "null"
+          ]
+        }
+      },
+      "required": [
+        "name",
+        "completed"
+      ]
+    },
+    {
+      "type": "object",
+      "properties": {
+        "id": {
+          "type": "integer",
+          "minimum": 0,
+          "maximum": 1000000
+        },
+        "completed_at": {
+          "type": [
+            "string",
+            "null"
+          ],
+          "format": "date-time"
+        },
+        "created_at": {
+          "type": "string",
+          "format": "date-time"
+        },
+        "updated_at": {
+          "type": "string",
+          "format": "date-time"
+        }
+      },
+      "required": [
+        "id",
+        "user"
+      ]
+    }
+  ]
+}
+====command====
+lint {document} --ruleset ./test-harness/scenarios/rulesets/loose-schema-ruleset.json
+====stdout====
+JSON Schema (loose) detected
+
+{document}
+ 3:11    error  no-root-all-of        No root allOf combiner
+ 6:22  warning  no-empty-description  Description must not be empty
+
+✖ 2 problems (1 error, 1 warning, 0 infos, 0 hints)