resume-cli 3.4.0 → 3.5.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.
package/README.md CHANGED
@@ -39,7 +39,35 @@ Complete the `resume.json` with your text editor. Be sure to follow the schema (
39
39
 
40
40
  ### `resume validate`
41
41
 
42
- Validates your `resume.json` against our schema to ensure it complies with the standard. Tries to identify where any errors may be occurring.
42
+ Validates your `resume.json` against our schema to ensure it complies with the standard.
43
+
44
+ On success it prints a one-line summary with the candidate name and a count of
45
+ each populated section:
46
+
47
+ ```
48
+ ✓ resume.json is valid (Ada Lovelace — 1 work, 1 education, 2 skills)
49
+ ```
50
+
51
+ On failure it exits non-zero and prints one annotated block per problem, naming
52
+ the exact JSON path, the failing rule, the offending value, and a one-line hint:
53
+
54
+ ```
55
+ Invalid resume: 2 problems found
56
+
57
+ ✖ data/basics/email must match format "email"
58
+ at: basics.email
59
+ rule: format (expected email)
60
+ found: "nope" (string)
61
+ hint: "basics.email" must be a valid email address, e.g. "you@example.com".
62
+
63
+ ✖ data/work/0/startDate must match pattern "..."
64
+ at: work[0].startDate
65
+ rule: pattern
66
+ found: "13-2020" (string)
67
+ hint: "work[0].startDate" must be an ISO-8601 date: YYYY, YYYY-MM, or YYYY-MM-DD.
68
+ ```
69
+
70
+ Validate against a custom schema with `--schema <path>`.
43
71
 
44
72
  ### `resume export [fileName]`
45
73
 
package/build/main.js CHANGED
@@ -17,6 +17,9 @@ const {
17
17
  ThemeNotFoundError,
18
18
  formatThemeNotFound
19
19
  } = require('./theme-errors');
20
+ const {
21
+ formatOkSummary
22
+ } = require('./validate-errors');
20
23
  const normalizeTheme = (value, defaultValue) => {
21
24
  const theme = value || defaultValue;
22
25
  // TODO - This is not great, but bypasses this function if it is a relative path
@@ -44,9 +47,9 @@ const normalizeTheme = (value, defaultValue) => {
44
47
  resume,
45
48
  schema
46
49
  });
47
- console.log(chalk.green(`✓ ${program.resume} is valid`));
50
+ console.log(chalk.green(formatOkSummary(resume, program.resume)));
48
51
  } catch (e) {
49
- console.error(e.message);
52
+ console.error(chalk.red(e.message));
50
53
  process.exitCode = 1;
51
54
  }
52
55
  });
@@ -108,7 +108,7 @@ describe('cli configuration', () => {
108
108
  it('should fail when trying to validate an invalid resume specified by the --resume option', async () => {
109
109
  expect((await run(['validate', '--resume', '/test-resumes/invalid-resume.json'])).code).toEqual(1);
110
110
  });
111
- it('should print the per-field error list (not a success line) when validation fails', async () => {
111
+ it('should print precise, path-pointed errors (not a success line) when validation fails', async () => {
112
112
  const {
113
113
  code,
114
114
  stdout,
@@ -119,7 +119,13 @@ describe('cli configuration', () => {
119
119
  });
120
120
  expect(code).toEqual(1);
121
121
  expect(stderr).toContain('Invalid resume:');
122
+ // The classic data-path phrasing is preserved...
122
123
  expect(stderr).toContain('data/basics/name must be string');
124
+ // ...alongside the new annotated path/rule/value/hint lines.
125
+ expect(stderr).toContain('at: basics.name');
126
+ expect(stderr).toContain('rule: type (expected string)');
127
+ expect(stderr).toContain('found: 123 (number)');
128
+ expect(stderr).toContain('must be of type string');
123
129
  // The success line must not appear for an invalid resume.
124
130
  expect(stdout).not.toContain('is valid');
125
131
  });
@@ -128,7 +134,7 @@ describe('cli configuration', () => {
128
134
  stdout
129
135
  } = await run(['validate', '--resume', '/test-resumes/resume.json']);
130
136
  expect(stdout).toMatchInlineSnapshot(`
131
- "✓ /test-resumes/resume.json is valid
137
+ "✓ /test-resumes/resume.json is valid (thomas)
132
138
  "
133
139
  `);
134
140
  });
@@ -0,0 +1,192 @@
1
+ "use strict";
2
+
3
+ // Turns raw Ajv (draft-07) error objects into precise, actionable output.
4
+ //
5
+ // For each failure we surface four things:
6
+ // - the JSON path in dot/bracket notation (e.g. work[2].startDate)
7
+ // - the failing rule (the Ajv keyword, lightly humanised)
8
+ // - the offending value (with its JS type)
9
+ // - a one-line, human hint on how to fix it
10
+ //
11
+ // The first physical line of each error is kept as `<dataPath> <message>`
12
+ // (e.g. `data/basics/name must be string`) so existing tooling that greps the
13
+ // output for that classic phrasing keeps working.
14
+
15
+ // "/work/1/startDate" -> "work[1].startDate"; the root is "(root)".
16
+ const toDotPath = instancePath => {
17
+ if (!instancePath) {
18
+ return '(root)';
19
+ }
20
+ const segments = instancePath.split('/').filter(Boolean);
21
+ return segments.map(segment => /^\d+$/.test(segment) ? `[${segment}]` : `.${segment}`).join('').replace(/^\./, '');
22
+ };
23
+
24
+ // "data" + instancePath, matching Ajv's classic dataPath phrasing.
25
+ const toDataPath = instancePath => instancePath ? `data${instancePath}` : 'data';
26
+ const previewValue = value => {
27
+ if (value === undefined) {
28
+ return 'undefined';
29
+ }
30
+ let serialised;
31
+ try {
32
+ serialised = JSON.stringify(value);
33
+ } catch {
34
+ serialised = String(value);
35
+ }
36
+ if (serialised === undefined) {
37
+ serialised = String(value);
38
+ }
39
+ if (serialised.length > 80) {
40
+ serialised = `${serialised.slice(0, 77)}...`;
41
+ }
42
+ const type = Array.isArray(value) ? 'array' : typeof value;
43
+ return `${serialised} (${type})`;
44
+ };
45
+
46
+ // Resolve the value the error points at by walking the instancePath.
47
+ const valueAtPath = (root, instancePath) => {
48
+ if (!instancePath) {
49
+ return root;
50
+ }
51
+ const segments = instancePath.split('/').filter(Boolean);
52
+ let current = root;
53
+ for (const segment of segments) {
54
+ if (current == null) {
55
+ return undefined;
56
+ }
57
+ // Ajv JSON-pointer-escapes "/" as "~1" and "~" as "~0".
58
+ const key = segment.replace(/~1/g, '/').replace(/~0/g, '~');
59
+ current = current[key];
60
+ }
61
+ return current;
62
+ };
63
+
64
+ // Map an Ajv keyword to a short human description of the rule it enforces.
65
+ const describeRule = error => {
66
+ const {
67
+ keyword,
68
+ params
69
+ } = error;
70
+ switch (keyword) {
71
+ case 'type':
72
+ return `type (expected ${params.type})`;
73
+ case 'format':
74
+ return `format (expected ${params.format})`;
75
+ case 'pattern':
76
+ return 'pattern';
77
+ case 'required':
78
+ return `required (${params.missingProperty})`;
79
+ case 'additionalProperties':
80
+ return `additionalProperties (${params.additionalProperty})`;
81
+ case 'enum':
82
+ return `enum (one of ${JSON.stringify(params.allowedValues)})`;
83
+ case 'minimum':
84
+ case 'maximum':
85
+ case 'minLength':
86
+ case 'maxLength':
87
+ case 'minItems':
88
+ case 'maxItems':
89
+ return `${keyword} (${params.limit})`;
90
+ default:
91
+ return keyword;
92
+ }
93
+ };
94
+
95
+ // A short, plain-language fix for the failing field.
96
+ const hintFor = (error, dotPath) => {
97
+ const field = dotPath === '(root)' ? 'the resume' : `"${dotPath}"`;
98
+ const {
99
+ keyword,
100
+ params
101
+ } = error;
102
+ switch (keyword) {
103
+ case 'type':
104
+ return `${field} must be of type ${params.type}.`;
105
+ case 'format':
106
+ if (params.format === 'email') {
107
+ return `${field} must be a valid email address, e.g. "you@example.com".`;
108
+ }
109
+ if (params.format === 'uri') {
110
+ return `${field} must be a full URL, e.g. "https://example.com".`;
111
+ }
112
+ if (params.format === 'date') {
113
+ return `${field} must be an ISO date, e.g. "2024-01-31".`;
114
+ }
115
+ return `${field} must match the "${params.format}" format.`;
116
+ case 'pattern':
117
+ // The JSON Resume date fields share one ISO-8601 pattern.
118
+ if (/iso8601/.test(error.schemaPath || '')) {
119
+ return `${field} must be an ISO-8601 date: YYYY, YYYY-MM, or YYYY-MM-DD.`;
120
+ }
121
+ return `${field} does not match the required pattern.`;
122
+ case 'required':
123
+ return `add the missing "${params.missingProperty}" property to ${field}.`;
124
+ case 'additionalProperties':
125
+ return `remove the unknown property "${params.additionalProperty}" from ${field} (or check for a typo).`;
126
+ case 'enum':
127
+ return `${field} must be one of: ${params.allowedValues.map(v => JSON.stringify(v)).join(', ')}.`;
128
+ case 'minLength':
129
+ return `${field} must be at least ${params.limit} character(s) long.`;
130
+ case 'maxLength':
131
+ return `${field} must be at most ${params.limit} character(s) long.`;
132
+ case 'minItems':
133
+ return `${field} must contain at least ${params.limit} item(s).`;
134
+ case 'minimum':
135
+ return `${field} must be >= ${params.limit}.`;
136
+ case 'maximum':
137
+ return `${field} must be <= ${params.limit}.`;
138
+ default:
139
+ return `${field} ${error.message}.`;
140
+ }
141
+ };
142
+
143
+ // Format a single Ajv error into a multi-line, annotated block.
144
+ const formatSingleError = (error, root) => {
145
+ const dataPath = toDataPath(error.instancePath);
146
+ const dotPath = toDotPath(error.instancePath);
147
+ // For `required`/`additionalProperties` the offending value is the *parent*
148
+ // object; show that, since the named property itself is missing/extra.
149
+ const value = valueAtPath(root, error.instancePath);
150
+ const lines = [
151
+ // Kept verbatim so `data/basics/name must be string`-style greps work.
152
+ `✖ ${dataPath} ${error.message}`, ` at: ${dotPath}`, ` rule: ${describeRule(error)}`];
153
+ if (error.keyword !== 'required') {
154
+ lines.push(` found: ${previewValue(value)}`);
155
+ }
156
+ lines.push(` hint: ${hintFor(error, dotPath)}`);
157
+ return lines.join('\n');
158
+ };
159
+
160
+ // Public: build the full failure message.
161
+ // formatErrors(ajvErrors, resume) -> string
162
+ const formatErrors = (errors = [], root) => {
163
+ const count = errors.length;
164
+ const heading = count === 1 ? 'Invalid resume: 1 problem found' : `Invalid resume: ${count} problems found`;
165
+ const blocks = errors.map(error => formatSingleError(error, root));
166
+ return `${heading}\n\n ${blocks.join('\n\n ')}`;
167
+ };
168
+
169
+ // Build a one-line OK summary that counts the populated array sections.
170
+ const formatOkSummary = (resume, label) => {
171
+ const sections = ['work', 'volunteer', 'education', 'awards', 'certificates', 'publications', 'skills', 'languages', 'interests', 'references', 'projects'];
172
+ const counts = sections.filter(key => Array.isArray(resume && resume[key]) && resume[key].length).map(key => `${resume[key].length} ${key}`);
173
+ const name = resume && resume.basics && typeof resume.basics.name === 'string' ? resume.basics.name : null;
174
+ const parts = [];
175
+ if (name) {
176
+ parts.push(name);
177
+ }
178
+ if (counts.length) {
179
+ parts.push(counts.join(', '));
180
+ }
181
+ const detail = parts.length ? ` (${parts.join(' — ')})` : '';
182
+ const subject = label ? `${label} is valid` : 'resume is valid';
183
+ return `✓ ${subject}${detail}`;
184
+ };
185
+ module.exports = {
186
+ formatErrors,
187
+ formatOkSummary,
188
+ // exported for unit testing of the path/value/hint helpers
189
+ toDotPath,
190
+ previewValue,
191
+ hintFor
192
+ };
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+
3
+ const {
4
+ formatErrors,
5
+ formatOkSummary,
6
+ toDotPath,
7
+ previewValue
8
+ } = require('./validate-errors');
9
+
10
+ // A representative slice of real Ajv (draft-07) error objects, as produced by
11
+ // `ajv.compile(schema)` against a resume with several distinct violations.
12
+ const sampleAjvErrors = [{
13
+ instancePath: '/basics/name',
14
+ schemaPath: '#/properties/basics/properties/name/type',
15
+ keyword: 'type',
16
+ params: {
17
+ type: 'string'
18
+ },
19
+ message: 'must be string'
20
+ }, {
21
+ instancePath: '/basics/email',
22
+ schemaPath: '#/properties/basics/properties/email/format',
23
+ keyword: 'format',
24
+ params: {
25
+ format: 'email'
26
+ },
27
+ message: 'must match format "email"'
28
+ }, {
29
+ instancePath: '/work/2/startDate',
30
+ schemaPath: '#/definitions/iso8601/pattern',
31
+ keyword: 'pattern',
32
+ params: {
33
+ pattern: '^(...)$'
34
+ },
35
+ message: 'must match pattern "..."'
36
+ }];
37
+ const sampleResume = {
38
+ basics: {
39
+ name: 123,
40
+ email: 'not-an-email'
41
+ },
42
+ work: [{}, {}, {
43
+ startDate: '13-2020'
44
+ }]
45
+ };
46
+ describe('toDotPath', () => {
47
+ it('converts a JSON pointer to dot/bracket notation', () => {
48
+ expect(toDotPath('/work/2/startDate')).toBe('work[2].startDate');
49
+ expect(toDotPath('/basics/name')).toBe('basics.name');
50
+ });
51
+ it('labels the document root', () => {
52
+ expect(toDotPath('')).toBe('(root)');
53
+ });
54
+ });
55
+ describe('previewValue', () => {
56
+ it('shows the value with its JS type', () => {
57
+ expect(previewValue(123)).toBe('123 (number)');
58
+ expect(previewValue('hi')).toBe('"hi" (string)');
59
+ expect(previewValue([1, 2])).toBe('[1,2] (array)');
60
+ });
61
+ it('reports undefined for missing values', () => {
62
+ expect(previewValue(undefined)).toBe('undefined');
63
+ });
64
+ });
65
+ describe('formatErrors', () => {
66
+ const output = formatErrors(sampleAjvErrors, sampleResume);
67
+ it('counts the problems in the heading', () => {
68
+ expect(output).toContain('Invalid resume: 3 problems found');
69
+ });
70
+ it('names the right JSON path for each error', () => {
71
+ expect(output).toContain('at: basics.name');
72
+ expect(output).toContain('at: basics.email');
73
+ expect(output).toContain('at: work[2].startDate');
74
+ });
75
+ it('reports the failing rule', () => {
76
+ expect(output).toContain('rule: type (expected string)');
77
+ expect(output).toContain('rule: format (expected email)');
78
+ expect(output).toContain('rule: pattern');
79
+ });
80
+ it('shows the offending value with its type', () => {
81
+ expect(output).toContain('found: 123 (number)');
82
+ expect(output).toContain('found: "not-an-email" (string)');
83
+ expect(output).toContain('found: "13-2020" (string)');
84
+ });
85
+ it('gives a one-line human hint per error', () => {
86
+ expect(output).toContain('"basics.name" must be of type string.');
87
+ expect(output).toContain('must be a valid email address');
88
+ expect(output).toContain('must be an ISO-8601 date');
89
+ });
90
+ it('preserves the classic data-path phrasing for greppability', () => {
91
+ expect(output).toContain('data/basics/name must be string');
92
+ });
93
+ it('uses the singular "problem" for a single error', () => {
94
+ const single = formatErrors([sampleAjvErrors[0]], sampleResume);
95
+ expect(single).toContain('Invalid resume: 1 problem found');
96
+ });
97
+ it('handles required + additionalProperties without throwing', () => {
98
+ const out = formatErrors([{
99
+ instancePath: '/work/0',
100
+ schemaPath: '#/.../required',
101
+ keyword: 'required',
102
+ params: {
103
+ missingProperty: 'name'
104
+ },
105
+ message: "must have required property 'name'"
106
+ }, {
107
+ instancePath: '/basics',
108
+ schemaPath: '#/.../additionalProperties',
109
+ keyword: 'additionalProperties',
110
+ params: {
111
+ additionalProperty: 'nme'
112
+ },
113
+ message: 'must NOT have additional properties'
114
+ }], {
115
+ work: [{}],
116
+ basics: {
117
+ nme: 'x'
118
+ }
119
+ });
120
+ expect(out).toContain('rule: required (name)');
121
+ expect(out).toContain('add the missing "name" property');
122
+ expect(out).toContain('rule: additionalProperties (nme)');
123
+ expect(out).toContain('remove the unknown property "nme"');
124
+ });
125
+ });
126
+ describe('formatOkSummary', () => {
127
+ it('summarises populated array sections and the candidate name', () => {
128
+ const summary = formatOkSummary({
129
+ basics: {
130
+ name: 'Thomas'
131
+ },
132
+ work: [{}, {}],
133
+ education: [{}],
134
+ skills: [{}, {}, {}]
135
+ }, 'resume.json');
136
+ expect(summary).toContain('✓ resume.json is valid');
137
+ expect(summary).toContain('Thomas');
138
+ expect(summary).toContain('2 work');
139
+ expect(summary).toContain('1 education');
140
+ expect(summary).toContain('3 skills');
141
+ });
142
+ it('omits the detail block when there is nothing to count', () => {
143
+ expect(formatOkSummary({
144
+ basics: {}
145
+ }, 'resume.json')).toBe('✓ resume.json is valid');
146
+ });
147
+ it('handles a non-object resume (custom schema override)', () => {
148
+ expect(formatOkSummary(123, 'only-number.json')).toBe('✓ only-number.json is valid');
149
+ });
150
+ });
package/build/validate.js CHANGED
@@ -6,16 +6,8 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports.default = void 0;
7
7
  var _ajv = _interopRequireDefault(require("ajv"));
8
8
  var _ajvFormats = _interopRequireDefault(require("ajv-formats"));
9
+ var _validateErrors = require("./validate-errors");
9
10
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
10
- // Format a single Ajv error into a readable `field message` line.
11
- const formatError = error => {
12
- // instancePath is like "/basics/name"; the root is an empty string.
13
- const field = error.instancePath ? `data${error.instancePath}` : 'data';
14
- if (error.keyword === 'additionalProperties') {
15
- return `${field} ${error.message} (${error.params.additionalProperty})`;
16
- }
17
- return `${field} ${error.message}`;
18
- };
19
11
  var _default = async ({
20
12
  resume,
21
13
  schema
@@ -32,7 +24,9 @@ var _default = async ({
32
24
  if (validateFn(resume)) {
33
25
  return true;
34
26
  }
35
- const details = (validateFn.errors || []).map(formatError).join('\n ');
36
- throw new Error(`Invalid resume:\n ${details}`);
27
+ const error = new Error((0, _validateErrors.formatErrors)(validateFn.errors || [], resume));
28
+ // Expose the raw Ajv errors so callers can build machine-readable output.
29
+ error.validationErrors = validateFn.errors || [];
30
+ throw error;
37
31
  };
38
32
  exports.default = _default;
@@ -16,7 +16,7 @@ describe('validate', () => {
16
16
  schema: defaultSchema
17
17
  });
18
18
  });
19
- it('should throw a per-field error for an invalid resume object', async () => {
19
+ it('should throw a precise, path-pointed error for an invalid resume object', async () => {
20
20
  await expect((0, _validate.default)({
21
21
  resume: {
22
22
  basics: {
@@ -25,10 +25,55 @@ describe('validate', () => {
25
25
  },
26
26
  schema: defaultSchema
27
27
  })).rejects.toMatchInlineSnapshot(`
28
- [Error: Invalid resume:
29
- data/basics/name must be string]
28
+ [Error: Invalid resume: 1 problem found
29
+
30
+ ✖ data/basics/name must be string
31
+ at: basics.name
32
+ rule: type (expected string)
33
+ found: 123 (number)
34
+ hint: "basics.name" must be of type string.]
30
35
  `);
31
36
  });
37
+ it('should report every distinct violation with its own JSON path', async () => {
38
+ let thrown;
39
+ try {
40
+ await (0, _validate.default)({
41
+ resume: {
42
+ basics: {
43
+ name: 123,
44
+ email: 'not-an-email'
45
+ },
46
+ work: [{
47
+ name: 'Acme',
48
+ startDate: '13-2020'
49
+ }]
50
+ },
51
+ schema: defaultSchema
52
+ });
53
+ } catch (e) {
54
+ thrown = e;
55
+ }
56
+ expect(thrown).toBeDefined();
57
+ expect(thrown.message).toContain('Invalid resume: 3 problems found');
58
+ // Each error names the right JSON path in dot/bracket notation.
59
+ expect(thrown.message).toContain('at: basics.name');
60
+ expect(thrown.message).toContain('at: basics.email');
61
+ expect(thrown.message).toContain('at: work[0].startDate');
62
+ // ...the failing rule...
63
+ expect(thrown.message).toContain('rule: type (expected string)');
64
+ expect(thrown.message).toContain('rule: format (expected email)');
65
+ // ...the offending value...
66
+ expect(thrown.message).toContain('found: 123 (number)');
67
+ expect(thrown.message).toContain('found: "not-an-email" (string)');
68
+ // ...and a human hint.
69
+ expect(thrown.message).toContain('must be a valid email address');
70
+ expect(thrown.message).toContain('must be an ISO-8601 date');
71
+ // The classic `data/...` phrasing is preserved for greppability.
72
+ expect(thrown.message).toContain('data/basics/name must be string');
73
+ // Raw Ajv errors are exposed for machine-readable callers.
74
+ expect(Array.isArray(thrown.validationErrors)).toBe(true);
75
+ expect(thrown.validationErrors).toHaveLength(3);
76
+ });
32
77
  it('should accept a schema override', async () => {
33
78
  await (0, _validate.default)({
34
79
  resume: 123,
@@ -42,8 +87,13 @@ describe('validate', () => {
42
87
  type: 'number'
43
88
  }
44
89
  })).rejects.toMatchInlineSnapshot(`
45
- [Error: Invalid resume:
46
- data must be number]
90
+ [Error: Invalid resume: 1 problem found
91
+
92
+ ✖ data must be number
93
+ at: (root)
94
+ rule: type (expected number)
95
+ found: "thomas" (string)
96
+ hint: the resume must be of type number.]
47
97
  `);
48
98
  });
49
99
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resume-cli",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "The JSON Resume command line interface",
5
5
  "main": "build/main.js",
6
6
  "engines": {