@gesslar/bedoc 1.9.0 → 1.10.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.
@@ -0,0 +1,50 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://bedoc.gesslar.dev/schemas/v1/bedoc.action.json",
4
+ "title": "BeDoc Action Schema",
5
+ "type": "object",
6
+ "properties": {
7
+ "accepts": {
8
+ "type": "object",
9
+ "properties": {
10
+ "type": {
11
+ "const": "object"
12
+ }
13
+ },
14
+ "required": [
15
+ "type"
16
+ ]
17
+ },
18
+ "provides": {
19
+ "type": "object",
20
+ "properties": {
21
+ "type": {
22
+ "const": "object"
23
+ }
24
+ },
25
+ "required": [
26
+ "type"
27
+ ],
28
+ "not": {
29
+ "properties": {
30
+ "required": {}
31
+ },
32
+ "required": [
33
+ "required"
34
+ ]
35
+ }
36
+ }
37
+ },
38
+ "oneOf": [
39
+ {
40
+ "required": [
41
+ "accepts"
42
+ ]
43
+ },
44
+ {
45
+ "required": [
46
+ "provides"
47
+ ]
48
+ }
49
+ ]
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gesslar/bedoc",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Pluggable documentation engine for any language and format",
5
5
  "publisher": "gesslar",
6
6
  "author": "gesslar",
@@ -37,6 +37,7 @@
37
37
  "lint:fix": "npx eslint . --fix"
38
38
  },
39
39
  "dependencies": {
40
+ "ajv": "^8.17.1",
40
41
  "commander": "^13.0.0",
41
42
  "dotenv": "^16.4.7",
42
43
  "error-stack-parser": "^2.1.4",
@@ -0,0 +1,112 @@
1
+ import yaml from "yaml"
2
+ import JSON5 from "json5"
3
+
4
+ import * as FDUtil from "./util/FDUtil.js"
5
+ import * as ContractUtil from "./util/ContractUtil.js"
6
+ import * as DataUtil from "./util/DataUtil.js"
7
+
8
+ const {resolveFilename, readFile} = FDUtil
9
+ const {loadSchema, getValidator} = ContractUtil
10
+ const {findClosestMatch} = DataUtil
11
+
12
+ const refex = /^ref:\/\/(.*)$/
13
+
14
+ export default class ContractManager {
15
+ static async newContract(actionType, terms) {
16
+ // Load and validate against the BeDoc contract schema
17
+ const schema = await loadSchema()
18
+ const validator = getValidator(schema)
19
+ const valid = validator(terms)
20
+
21
+ if(!valid) {
22
+ const error = ContractManager.reportValidationErrors(validator.errors)
23
+
24
+ throw new Error(`Invalid contract terms:\n${error}`)
25
+ }
26
+
27
+ const dataValidator = getValidator({
28
+ "$schema": "http://json-schema.org/draft-07/schema#",
29
+ "$id": `${actionType} Schema`,
30
+ title: `${actionType} Schema`,
31
+ type: "object",
32
+ properties: terms,
33
+ })
34
+
35
+ return new Contract(dataValidator)
36
+ }
37
+
38
+ static parse(contractData, directoryObject) {
39
+ if(typeof contractData === "string") {
40
+ const match = refex.exec(contractData)
41
+
42
+ if(match)
43
+ contractData = readFile(resolveFilename(match[1], directoryObject))
44
+
45
+ return yaml.parse(String(contractData))
46
+ }
47
+
48
+ throw new Error(`Invalid contract data: ${JSON5.stringify(contractData)}`)
49
+ }
50
+
51
+ static reportValidationErrors(errors) {
52
+ return errors.reduce((acc, error) => {
53
+ let msg = `- "${error.instancePath || "(root)"}" ${error.message}`
54
+
55
+ if(error.params) {
56
+ const details = []
57
+
58
+ if(error.params.type)
59
+ details.push(` ➜ Expected type: ${error.params.type}`)
60
+
61
+ if(error.params.missingProperty)
62
+ details.push(` ➜ Missing required field: ${error.params.missingProperty}`)
63
+
64
+ if(error.params.allowedValues) {
65
+ details.push(` ➜ Allowed values: "${error.params.allowedValues.join('", "')}"`)
66
+ details.push(` ➜ Received value: "${error.data}"`)
67
+ const closestMatch =
68
+ findClosestMatch(error.data, error.params.allowedValues)
69
+ if(closestMatch)
70
+ details.push(` ➜ Did you mean: "${closestMatch}"?`)
71
+ }
72
+
73
+ if(error.params.pattern)
74
+ details.push(` ➜ Expected pattern: ${error.params.pattern}`)
75
+
76
+ if(error.params.format)
77
+ details.push(` ➜ Expected format: ${error.params.format}`)
78
+
79
+ if(error.params.additionalProperty)
80
+ details.push(` ➜ Unexpected property: ${error.params.additionalProperty}`)
81
+
82
+ if(details.length)
83
+ msg += `\n${details.join("\n")}`
84
+ }
85
+
86
+ return acc ? `${acc}\n${msg}` : msg
87
+ }, "")
88
+ }
89
+ }
90
+
91
+ class Contract {
92
+ #validator = null
93
+
94
+ constructor(validator) {
95
+ this.#validator = validator
96
+ }
97
+
98
+ get validator() {
99
+ return this.#validator
100
+ }
101
+
102
+ validate(data) {
103
+ const validator = this.validator
104
+ const valid = validator(data)
105
+
106
+ if(!valid) {
107
+ const error = ContractManager.reportValidationErrors(validator.errors)
108
+
109
+ throw new Error(`This document violates the agreed upon contract:\n${error}`)
110
+ }
111
+ }
112
+ }
@@ -107,6 +107,9 @@ export default class Conveyor {
107
107
  return {status: "warning", file, warning: mess}
108
108
  }
109
109
 
110
+ parse.contract.validate(parseResult)
111
+ print.contract.validate(parseResult)
112
+
110
113
  // Step 3: Print file
111
114
  const printResult = await print.runAction({
112
115
  file,
package/src/core/Core.js CHANGED
@@ -42,7 +42,7 @@ export default class Core {
42
42
  const instance = new Core({...validConfig, name: "BeDoc"})
43
43
  const debug = instance.logger.newDebug()
44
44
 
45
- debug("Creating new BeDoc instance with options: `%o`", 2, validConfig)
45
+ debug("Creating new BeDoc instance with options: `%o`", 3, validConfig)
46
46
 
47
47
  const discovery = new Discovery(instance)
48
48
  const actionDefs = await discovery.discoverActions({
@@ -52,7 +52,7 @@ export default class Core {
52
52
 
53
53
  const validCrit = discovery.satisfyCriteria(actionDefs, validConfig)
54
54
 
55
- debug("Actions that met criteria: `%o`", 2, validCrit)
55
+ debug("Actions that met criteria: `%o`", 3, validCrit)
56
56
 
57
57
  if(Object.values(validCrit).some(arr => arr.length === 0))
58
58
  throw new Error("No found matching parser and printer")
@@ -63,6 +63,7 @@ export default class Core {
63
63
  const printer = validCrit.print[printers]
64
64
  const printerSchema = printer.contract
65
65
  const satisfied = []
66
+
66
67
  for(const parser of validCrit.parse) {
67
68
  const parserSchema = parser.contract
68
69
  const result = schemaCompare(parserSchema, printerSchema)
@@ -1,12 +1,13 @@
1
- // import {process} from "node:process"
2
- import yaml from "yaml"
3
1
  import {execSync} from "child_process"
2
+ import path from "node:path"
4
3
 
4
+ import ContractManager from "./ContractManager.js"
5
5
  import * as FDUtil from "./util/FDUtil.js"
6
6
  import * as ActionUtil from "./util/ActionUtil.js"
7
7
  import * as DataUtil from "./util/DataUtil.js"
8
- import {composeDirectory,directoryExists} from "./util/FDUtil.js"
9
8
 
9
+ const {composeDirectory,directoryExists,resolveDirectory} = FDUtil
10
+ const {newContract,parse} = ContractManager
10
11
  const {ls,fileExists,composeFilename,getFiles} = FDUtil
11
12
  const {actionTypes, actionMetaRequirements, loadJson} = ActionUtil
12
13
  const {isType} = DataUtil
@@ -34,15 +35,16 @@ export default class Discovery {
34
35
 
35
36
  debug("Discovering actions", 2)
36
37
 
37
- debug("Specific modules provided: %o", 2, specific)
38
+ debug("Specific modules provided: %o", 2, Object.values(specific).filter(Boolean).length)
39
+ debug("Specific modules provided: %o", 3, specific)
38
40
 
39
- const bucket = []
41
+ const files = []
40
42
  const options = this.core.options ?? {}
41
43
 
42
44
  if(options?.mockPath) {
43
45
  debug("Discovering mock actions in `%s`", 2, options.mockPath)
44
46
 
45
- bucket.push(
47
+ files.push(
46
48
  ...(await getFiles([
47
49
  `${options.mockPath}/bedoc-*-printer.js`,
48
50
  `${options.mockPath}/bedoc-*-parser.js`,
@@ -52,22 +54,20 @@ export default class Discovery {
52
54
  debug("Mock path not set, discovering actions in node_modules", 2)
53
55
 
54
56
  debug("Looking for actions in project's package.json", 2)
57
+ if(this.core.packageJson) {
58
+ const exported = (this.core.packageJson.modules || [])
59
+ .map(m => composeFilename(options.basePath, m))
60
+ .flat()
55
61
 
56
- if(this.core.packageJson?.modules) {
57
- const actions = this.core.packageJson?.modules
62
+ debug("Found %o modules in project's package.json", 2, exported.length)
63
+ debug("Found modules in project's package.json: %o", 2, exported)
58
64
 
59
- debug("Found %o actions in package.json", 3, actions)
60
- debug("Actions found in package.json action in package.json: %o", 3, actions)
61
-
62
- if(actions && typeof(actions) === "object")
63
- bucket.push(...actions)
64
- else
65
- debug("No actions found in package.json", 3)
65
+ files.push(...exported)
66
66
  } else {
67
- debug("No actions found in project's package.json", 2)
67
+ debug("No modules found in project's package.json", 2)
68
68
  }
69
69
 
70
- debug("Looking for actions in node_modules (global and locally installed)", 2)
70
+ debug("Looking for modules in node_modules (global and locally installed)", 2)
71
71
  const directories = [
72
72
  execSync("npm root").toString().trim(),
73
73
  execSync("npm root -g").toString().trim(),
@@ -96,58 +96,52 @@ export default class Discovery {
96
96
  const {directories: scopedPackages} = await ls(scopedDir.absolutePath)
97
97
 
98
98
  debug("Found %o directories under scoped package %o", 2, directories.length, scopedDir.name)
99
+ debug("Found directories under scoped package %o\n%o", 2, scopedDir.absolutePath, scopedPackages.map(d => d.absolutePath))
99
100
 
100
101
  dirsToSearch.push(...scopedPackages)
101
102
  }
102
103
 
103
- debug("Found %o directories to search for actions", 2, dirsToSearch.length)
104
-
105
- const exports = dirsToSearch
106
- .filter(d => !d.name.startsWith("."))
107
- .map(d => this.#getModuleExports(d))
108
-
109
- debug("Found %o module exports under %o", 2, exports.length, nodeModulesDir.absolutePath)
104
+ debug("1 Found %o directories to search for actions", 2, dirsToSearch.length)
105
+ debug("2 Found directories to search for actions: %o", 3, dirsToSearch)
110
106
 
111
- bucket.push(...exports.flat())
112
- }
113
- }
107
+ const visibleDirs = dirsToSearch.filter(d => !d.name.startsWith("."))
114
108
 
115
- debug("Discovered %d actions", 2, bucket.length)
109
+ for(const dir of visibleDirs) {
110
+ const packageJsonFile = composeFilename(dir, "package.json")
116
111
 
117
- return await this.#loadActionsAndContracts(bucket, specific)
118
- }
112
+ if(!fileExists(packageJsonFile))
113
+ continue
119
114
 
120
- /**
121
- * Get the exports from a module's package.json file, resolved to file paths
122
- *
123
- * @param {object} dirMap The directory map object
124
- * @returns {object[]} The discovered module exports
125
- */
126
- #getModuleExports(dirMap) {
127
- const debug = this.#debug
128
- debug("Getting module exports from %o", 3, dirMap.absolutePath)
115
+ const packageJson = loadJson(packageJsonFile)
116
+ if(!packageJson.bedoc)
117
+ continue
129
118
 
130
- const packageJsonFile = composeFilename(dirMap, "package.json")
131
- if(!fileExists(packageJsonFile))
132
- return []
119
+ const {modules} = packageJson.bedoc ?? null
120
+ if(!modules || !Array.isArray(modules))
121
+ continue
133
122
 
134
- debug("Loading package.json from %o", 3, packageJsonFile.absolutePath)
123
+ const moduleFiles = modules
124
+ .map(f => composeFilename(dir, f))
125
+ .filter(f => fileExists(f))
135
126
 
136
- const packageJson = loadJson(packageJsonFile)
137
- debug("Loaded package.json from %o", 3, packageJsonFile.absolutePath)
127
+ debug("Discovered %d modules from package.json file: %o", 2,
128
+ modules.length,
129
+ packageJsonFile.absolutePath
130
+ )
131
+ debug("Discovered from package.json files: %o", 3, modules)
138
132
 
139
- const bedocPackageJsonModules = packageJson.bedoc?.modules ?? []
140
-
141
- debug("Discovered %o published modules", 2, bedocPackageJsonModules.length)
142
- debug("Published modules %o", 3, bedocPackageJsonModules)
133
+ files.push(...moduleFiles)
134
+ }
135
+ }
136
+ }
143
137
 
144
- const bedocModuleFiles = bedocPackageJsonModules
145
- .map(m => composeFilename(dirMap, m))
146
- .filter(m => fileExists(m))
138
+ debug("Discovered %d modules", 2, files.length)
139
+ debug("Discovered modules", 2, files.map(f => f.path))
140
+ debug("Discovered modules %o", 3, files)
147
141
 
148
- debug("Composed modules %o", 3, bedocModuleFiles)
142
+ // const available = files.map(f => this.#getModuleExports(f))
149
143
 
150
- return bedocModuleFiles
144
+ return await this.#loadActionsAndContracts(files, specific)
151
145
  }
152
146
 
153
147
  /**
@@ -163,7 +157,7 @@ export default class Discovery {
163
157
 
164
158
  debug("Loading actions and contracts", 2)
165
159
  debug("Loading %d module files", 2, moduleFiles.length)
166
- debug("Specific modules to load: %o", 2, specificModules)
160
+ debug("Specific modules to load: %o", 3, specificModules)
167
161
 
168
162
  const resultActions = {}
169
163
  actionTypes.forEach(actionType => (resultActions[actionType] = []))
@@ -190,12 +184,19 @@ export default class Discovery {
190
184
  debug("Loading module `%s`", 2, file.absoluteUri)
191
185
 
192
186
  const loading = await this.#loadModule(file)
193
- const loaded = loading.actions.map((action, index) => {
194
- const contract = yaml.parse(loading.contracts[index])
187
+ for(let index = 0; index < loading.actions.length; index++) {
188
+ const action = loading.actions[index]
195
189
 
196
- return {file, action, contract}
197
- })
198
- loadedActions.push(...loaded)
190
+ if(!file.directory)
191
+ file.directory = resolveDirectory(path.dirname(file.path))
192
+
193
+ debug(`Loading %o contract from %o`, 2, action.meta.action, file.path)
194
+
195
+ const terms = parse(loading.contracts[index], file.directory)
196
+ const contract = await newContract(action.meta.action, terms)
197
+
198
+ loadedActions.push({file, action, contract})
199
+ }
199
200
  }
200
201
 
201
202
  debug("Loaded %d actions", 2, loadedActions.length)
@@ -350,7 +351,7 @@ export default class Discovery {
350
351
  async #loadModule(module) {
351
352
  const debug = this.#debug
352
353
 
353
- debug("2 Loading module `%j`", 2, module)
354
+ debug("Loading module `%j`", 2, module)
354
355
 
355
356
  const {absoluteUri} = module
356
357
  const moduleExports = await import(absoluteUri)
@@ -0,0 +1,7 @@
1
+ import {Contract} from "../ContractManager.js"
2
+
3
+ export default class ParseContract extends Contract {
4
+ constructor(actionDefinition, logger) {
5
+ super(actionDefinition, logger)
6
+ }
7
+ }
@@ -0,0 +1,7 @@
1
+ import {Contract} from "../ContractManager.js"
2
+
3
+ export default class PrintContract extends Contract {
4
+ constructor(actionDefinition, logger) {
5
+ super(actionDefinition, logger)
6
+ }
7
+ }
@@ -0,0 +1,63 @@
1
+ import fetch from "node-fetch"
2
+ import JSON5 from "json5"
3
+ import Ajv from "ajv"
4
+ import {fileURLToPath,URL} from "node:url"
5
+
6
+ import * as FDUtil from "./FDUtil.js"
7
+
8
+ const {composeFilename,fileExists,readFile,writeFile} = FDUtil
9
+
10
+ const schemaUrl = "https://bedoc.gesslar.dev/schemas/v1/bedoc.action.json"
11
+ const localSchema = "./dist/bedoc.action.json"
12
+
13
+ /**
14
+ * Takes a schema and returns a validator function
15
+ *
16
+ * @param {object} schema The schema to compile
17
+ * @returns {Function} The schema validator function
18
+ */
19
+ function getValidator(schema) {
20
+ const ajv = new Ajv({allErrors: true, verbose: true})
21
+ const f = ajv.compile(schema)
22
+
23
+ return f
24
+ }
25
+
26
+ /**
27
+ * Downloads and preserves a copy of the action schema
28
+ * within the dist/ folder.
29
+ *
30
+ * @returns {object} The schema validator
31
+ */
32
+ async function fetchSchema() {
33
+ const response = await fetch(schemaUrl)
34
+ const schema = await response.text()
35
+
36
+ const output = composeFilename(fileURLToPath(new URL("../../../", import.meta.url)), localSchema)
37
+ writeFile(output, schema)
38
+
39
+ return JSON5.parse(schema)
40
+ }
41
+
42
+ /**
43
+ * Loads a schema from file or fetches it if it is missing.
44
+ *
45
+ * @returns {object} The schema object
46
+ */
47
+ async function loadSchema() {
48
+ const schemaFile = composeFilename(fileURLToPath(new URL("../../../", import.meta.url)), localSchema)
49
+
50
+ if(fileExists(schemaFile)) {
51
+ const schema = readFile(schemaFile)
52
+
53
+ return JSON5.parse(schema)
54
+ }
55
+
56
+ return await fetchSchema()
57
+ }
58
+
59
+ export {
60
+ fetchSchema,
61
+ getValidator,
62
+ loadSchema,
63
+ }
@@ -383,6 +383,9 @@ function deepFreezeObject(obj) {
383
383
  /**
384
384
  * Validates that a schema matches the expected structure.
385
385
  *
386
+ * TODO get rid of this and all of its uses. We have a new
387
+ * contract system now.
388
+ *
386
389
  * @param {object} schema - The schema to validate.
387
390
  * @param {object} definition - The expected structure.
388
391
  * @param {Array} stack - The stack trace for nested validation.
@@ -452,6 +455,60 @@ function schemaCompare(schema, definition, stack = [], logger = new Logger()) {
452
455
  return {status: errors.length === 0 ? "success" : "error", errors}
453
456
  }
454
457
 
458
+ /**
459
+ * Determine the Levenshtein distance between two string values
460
+ *
461
+ * @param {string} a The first value for comparison.
462
+ * @param {string} b The second value for comparison.
463
+ * @returns {number} The Levenshtein distance
464
+ */
465
+ function levenshteinDistance(a, b) {
466
+ const matrix = Array.from({length: a.length + 1}, (_, i) =>
467
+ Array.from({length: b.length + 1}, (_, j) =>
468
+ (i === 0 ? j : j === 0 ? i : 0)
469
+ )
470
+ )
471
+
472
+ for(let i = 1; i <= a.length; i++) {
473
+ for(let j = 1; j <= b.length; j++) {
474
+ matrix[i][j] =
475
+ a[i - 1] === b[j - 1]
476
+ ? matrix[i - 1][j - 1]
477
+ : 1 + Math.min(
478
+ matrix[i - 1][j], matrix[i][j - 1],
479
+ matrix[i - 1][j - 1]
480
+ )
481
+ }
482
+ }
483
+
484
+ return matrix[a.length][b.length]
485
+ }
486
+
487
+ /**
488
+ * Determine the closest match between a string and allowed values
489
+ * from the Levenshtein distance.
490
+ *
491
+ * @param {string} input The input string to resolve
492
+ * @param {Array<string>} allowedValues The values which are permitted
493
+ * @returns {string} Suggested, probable match.
494
+ */
495
+ function findClosestMatch(input, allowedValues) {
496
+ const threshold = 2 // Max edit distance for a "close match"
497
+ let closestMatch = null
498
+ let closestDistance = Infinity
499
+
500
+ for(const value of allowedValues) {
501
+ const distance = levenshteinDistance(input, value)
502
+ if(distance < closestDistance && distance <= threshold) {
503
+ closestMatch = value
504
+ closestDistance = distance
505
+ }
506
+ }
507
+
508
+ return closestMatch
509
+ }
510
+
511
+
455
512
  export {
456
513
  // Classes
457
514
  TypeSpec,
@@ -465,6 +522,7 @@ export {
465
522
  arrayPad,
466
523
  cloneObject,
467
524
  deepFreezeObject,
525
+ findClosestMatch,
468
526
  isArrayUniform,
469
527
  isArrayUnique,
470
528
  isBaseType,
@@ -473,6 +531,7 @@ export {
473
531
  isObjectEmpty,
474
532
  isType,
475
533
  isValidType,
534
+ levenshteinDistance,
476
535
  mapObject,
477
536
  newTypeSpec,
478
537
  prependString,