@kaiohenricunha/harness 0.2.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/LICENSE +21 -0
- package/README.md +130 -0
- package/package.json +68 -0
- package/plugins/harness/.claude-plugin/plugin.json +8 -0
- package/plugins/harness/README.md +74 -0
- package/plugins/harness/bin/harness-check-instruction-drift.mjs +77 -0
- package/plugins/harness/bin/harness-check-spec-coverage.mjs +81 -0
- package/plugins/harness/bin/harness-detect-drift.mjs +53 -0
- package/plugins/harness/bin/harness-doctor.mjs +145 -0
- package/plugins/harness/bin/harness-init.mjs +89 -0
- package/plugins/harness/bin/harness-validate-skills.mjs +92 -0
- package/plugins/harness/bin/harness-validate-specs.mjs +70 -0
- package/plugins/harness/bin/harness.mjs +93 -0
- package/plugins/harness/hooks/guard-destructive-git.sh +58 -0
- package/plugins/harness/scripts/auto-update-manifest.mjs +20 -0
- package/plugins/harness/scripts/detect-branch-drift.mjs +81 -0
- package/plugins/harness/scripts/lib/output.sh +105 -0
- package/plugins/harness/scripts/refresh-worktrees.sh +35 -0
- package/plugins/harness/scripts/validate-settings.sh +202 -0
- package/plugins/harness/src/check-instruction-drift.mjs +127 -0
- package/plugins/harness/src/check-spec-coverage.mjs +95 -0
- package/plugins/harness/src/index.mjs +57 -0
- package/plugins/harness/src/init-harness-scaffold.mjs +121 -0
- package/plugins/harness/src/lib/argv.mjs +108 -0
- package/plugins/harness/src/lib/debug.mjs +37 -0
- package/plugins/harness/src/lib/errors.mjs +147 -0
- package/plugins/harness/src/lib/exit-codes.mjs +18 -0
- package/plugins/harness/src/lib/output.mjs +90 -0
- package/plugins/harness/src/spec-harness-lib.mjs +359 -0
- package/plugins/harness/src/validate-skills-inventory.mjs +148 -0
- package/plugins/harness/src/validate-specs.mjs +217 -0
- package/plugins/harness/templates/claude/hooks/guard-destructive-git.sh +50 -0
- package/plugins/harness/templates/claude/settings.headless.json +24 -0
- package/plugins/harness/templates/claude/settings.json +16 -0
- package/plugins/harness/templates/claude/skills-manifest.json +6 -0
- package/plugins/harness/templates/docs/repo-facts.json +17 -0
- package/plugins/harness/templates/docs/specs/README.md +36 -0
- package/plugins/harness/templates/githooks/pre-commit +9 -0
- package/plugins/harness/templates/workflows/ai-review.yml +28 -0
- package/plugins/harness/templates/workflows/detect-drift.yml +15 -0
- package/plugins/harness/templates/workflows/validate-skills.yml +36 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny argv parser built on Node's `util.parseArgs` (available since Node 18,
|
|
3
|
+
* engines.node `>=20`).
|
|
4
|
+
*
|
|
5
|
+
* Recognizes the harness-wide flag set:
|
|
6
|
+
* --help / -h bool
|
|
7
|
+
* --version / -V bool
|
|
8
|
+
* --json bool
|
|
9
|
+
* --verbose / -v bool
|
|
10
|
+
* --no-color bool
|
|
11
|
+
*
|
|
12
|
+
* Callers add their own flags via the `spec` parameter; the harness-wide set
|
|
13
|
+
* is merged in automatically.
|
|
14
|
+
*
|
|
15
|
+
* @typedef {object} FlagSpec
|
|
16
|
+
* @property {'boolean'|'string'} type
|
|
17
|
+
* @property {string} [short] single-letter alias
|
|
18
|
+
* @property {string|boolean} [default]
|
|
19
|
+
* @property {boolean} [multiple] allow repetition (array of values)
|
|
20
|
+
*
|
|
21
|
+
* @typedef {{ [name: string]: FlagSpec }} FlagsSpec
|
|
22
|
+
*
|
|
23
|
+
* @typedef {object} ParsedArgs
|
|
24
|
+
* @property {{ [name: string]: boolean|string|string[]|undefined }} flags
|
|
25
|
+
* @property {string[]} positional
|
|
26
|
+
* @property {boolean} help
|
|
27
|
+
* @property {boolean} version
|
|
28
|
+
* @property {boolean} json
|
|
29
|
+
* @property {boolean} verbose
|
|
30
|
+
* @property {boolean} noColor
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { parseArgs } from 'node:util';
|
|
34
|
+
|
|
35
|
+
/** Harness-wide flag set auto-included in every `parse()` call. */
|
|
36
|
+
export const HARNESS_FLAGS = Object.freeze({
|
|
37
|
+
help: { type: 'boolean', short: 'h' },
|
|
38
|
+
version: { type: 'boolean', short: 'V' },
|
|
39
|
+
json: { type: 'boolean' },
|
|
40
|
+
verbose: { type: 'boolean', short: 'v' },
|
|
41
|
+
'no-color': { type: 'boolean' },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse `argv` (typically `process.argv.slice(2)`) against `spec`.
|
|
46
|
+
* Throws `Error` with a `.code = 'USAGE_UNKNOWN_FLAG'` property on unknown
|
|
47
|
+
* flags so callers can map to `EXIT_CODES.USAGE` without string matching.
|
|
48
|
+
*
|
|
49
|
+
* @param {string[]} argv
|
|
50
|
+
* @param {FlagsSpec} [spec]
|
|
51
|
+
* @returns {ParsedArgs}
|
|
52
|
+
*/
|
|
53
|
+
export function parse(argv, spec = {}) {
|
|
54
|
+
const merged = { ...HARNESS_FLAGS, ...spec };
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = parseArgs({
|
|
58
|
+
args: argv,
|
|
59
|
+
options: /** @type {any} */ (merged),
|
|
60
|
+
allowPositionals: true,
|
|
61
|
+
strict: true,
|
|
62
|
+
});
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const wrapped = new Error(err instanceof Error ? err.message : String(err));
|
|
65
|
+
/** @type {any} */ (wrapped).code = 'USAGE_UNKNOWN_FLAG';
|
|
66
|
+
throw wrapped;
|
|
67
|
+
}
|
|
68
|
+
const values = /** @type {Record<string, any>} */ (parsed.values);
|
|
69
|
+
return {
|
|
70
|
+
flags: values,
|
|
71
|
+
positional: parsed.positionals,
|
|
72
|
+
help: Boolean(values.help),
|
|
73
|
+
version: Boolean(values.version),
|
|
74
|
+
json: Boolean(values.json),
|
|
75
|
+
verbose: Boolean(values.verbose),
|
|
76
|
+
noColor: Boolean(values['no-color']),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Render a conventional `--help` message.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} meta
|
|
84
|
+
* @param {string} meta.name e.g. "harness-validate-specs"
|
|
85
|
+
* @param {string} meta.synopsis e.g. "harness-validate-specs [OPTIONS]"
|
|
86
|
+
* @param {string} meta.description 1-2 sentence summary.
|
|
87
|
+
* @param {FlagsSpec} [meta.flags] Bin-specific flags to document (harness-wide flags are always appended).
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
export function helpText(meta) {
|
|
91
|
+
const lines = [
|
|
92
|
+
meta.synopsis,
|
|
93
|
+
'',
|
|
94
|
+
meta.description,
|
|
95
|
+
'',
|
|
96
|
+
'Options:',
|
|
97
|
+
];
|
|
98
|
+
const all = { ...(meta.flags ?? {}), ...HARNESS_FLAGS };
|
|
99
|
+
const longest = Math.max(...Object.keys(all).map((k) => k.length + 2));
|
|
100
|
+
for (const [name, def] of Object.entries(all)) {
|
|
101
|
+
const long = `--${name}`;
|
|
102
|
+
const short = /** @type {any} */ (def).short ? `, -${/** @type {any} */ (def).short}` : '';
|
|
103
|
+
lines.push(` ${long}${short}`.padEnd(longest + 6) + (def.type === 'string' ? '<value>' : ''));
|
|
104
|
+
}
|
|
105
|
+
lines.push('');
|
|
106
|
+
lines.push('Exit codes: 0 ok, 1 validation failure, 2 env error, 64 usage error.');
|
|
107
|
+
return lines.join('\n');
|
|
108
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug logger gated on `HARNESS_DEBUG=1`.
|
|
3
|
+
*
|
|
4
|
+
* Replaces silent `catch` blocks in `spec-harness-lib.mjs:28-29` and `:184-186`
|
|
5
|
+
* (legacy behavior) with opt-in diagnostic output. When the env flag is unset,
|
|
6
|
+
* `debug()` is a no-op — zero runtime cost in normal operation.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* } catch (err) {
|
|
10
|
+
* debug('git:rev-parse', err.message);
|
|
11
|
+
* return null;
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* @param {string} tag
|
|
15
|
+
* @param {...unknown} args
|
|
16
|
+
* @returns {void}
|
|
17
|
+
*/
|
|
18
|
+
export function debug(tag, ...args) {
|
|
19
|
+
if (process.env.HARNESS_DEBUG !== '1') return;
|
|
20
|
+
process.stderr.write(`[harness:${tag}] ${args.map(stringify).join(' ')}\n`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @returns {boolean} */
|
|
24
|
+
export function isDebug() {
|
|
25
|
+
return process.env.HARNESS_DEBUG === '1';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {unknown} v */
|
|
29
|
+
function stringify(v) {
|
|
30
|
+
if (v instanceof Error) return v.stack ?? v.message;
|
|
31
|
+
if (typeof v === 'string') return v;
|
|
32
|
+
try {
|
|
33
|
+
return JSON.stringify(v);
|
|
34
|
+
} catch {
|
|
35
|
+
return String(v);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured error taxonomy for every harness validator.
|
|
3
|
+
*
|
|
4
|
+
* Every validator emits instances of `ValidationError` rather than raw strings.
|
|
5
|
+
* This gives consumers a machine-parseable `.code`, `.file`, and `.pointer`
|
|
6
|
+
* while keeping `.toString()` readable for human-first CLI output.
|
|
7
|
+
*
|
|
8
|
+
* @typedef {object} StructuredError
|
|
9
|
+
* @property {string} code Stable enum value (see `ERROR_CODES`). Never rename.
|
|
10
|
+
* @property {string} message Human-readable one-liner. May contain identifier names.
|
|
11
|
+
* @property {string} [file] Repo-relative path where the error was detected.
|
|
12
|
+
* @property {string} [pointer] JSON Pointer or dotted key when the error is inside a structured file.
|
|
13
|
+
* @property {number} [line] 1-indexed line number when available.
|
|
14
|
+
* @property {string} [expected] Text describing what the validator expected.
|
|
15
|
+
* @property {string} [got] Text describing what was observed.
|
|
16
|
+
* @property {string} [hint] Actionable remediation suggestion.
|
|
17
|
+
* @property {'spec'|'skill'|'manifest'|'coverage'|'drift'|'scaffold'|'settings'|'env'|'usage'} [category]
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Stable error codes. Consumers may match on these; renames are breaking changes.
|
|
22
|
+
*/
|
|
23
|
+
export const ERROR_CODES = Object.freeze({
|
|
24
|
+
// spec
|
|
25
|
+
SPEC_JSON_INVALID: 'SPEC_JSON_INVALID',
|
|
26
|
+
SPEC_STATUS_INVALID: 'SPEC_STATUS_INVALID',
|
|
27
|
+
SPEC_MISSING_REQUIRED_FIELD: 'SPEC_MISSING_REQUIRED_FIELD',
|
|
28
|
+
SPEC_ID_MISMATCH: 'SPEC_ID_MISMATCH',
|
|
29
|
+
SPEC_LINKED_PATH_MISSING: 'SPEC_LINKED_PATH_MISSING',
|
|
30
|
+
SPEC_ACCEPTANCE_EMPTY: 'SPEC_ACCEPTANCE_EMPTY',
|
|
31
|
+
SPEC_DEPENDENCY_UNKNOWN: 'SPEC_DEPENDENCY_UNKNOWN',
|
|
32
|
+
// skill
|
|
33
|
+
SKILL_FRONTMATTER_MISSING: 'SKILL_FRONTMATTER_MISSING',
|
|
34
|
+
SKILL_NAME_MISMATCH: 'SKILL_NAME_MISMATCH',
|
|
35
|
+
// manifest
|
|
36
|
+
MANIFEST_CHECKSUM_MISMATCH: 'MANIFEST_CHECKSUM_MISMATCH',
|
|
37
|
+
MANIFEST_ENTRY_MISSING: 'MANIFEST_ENTRY_MISSING',
|
|
38
|
+
MANIFEST_ORPHAN_FILE: 'MANIFEST_ORPHAN_FILE',
|
|
39
|
+
MANIFEST_DEPENDENCY_CYCLE: 'MANIFEST_DEPENDENCY_CYCLE',
|
|
40
|
+
// coverage
|
|
41
|
+
COVERAGE_UNCOVERED: 'COVERAGE_UNCOVERED',
|
|
42
|
+
COVERAGE_NO_SPEC_RATIONALE: 'COVERAGE_NO_SPEC_RATIONALE',
|
|
43
|
+
COVERAGE_UNKNOWN_SPEC_ID: 'COVERAGE_UNKNOWN_SPEC_ID',
|
|
44
|
+
// drift
|
|
45
|
+
DRIFT_TEAM_COUNT: 'DRIFT_TEAM_COUNT',
|
|
46
|
+
DRIFT_PROTECTED_PATH: 'DRIFT_PROTECTED_PATH',
|
|
47
|
+
DRIFT_INSTRUCTION_FILES: 'DRIFT_INSTRUCTION_FILES',
|
|
48
|
+
DRIFT_INSTRUCTION_FILE_MISSING: 'DRIFT_INSTRUCTION_FILE_MISSING',
|
|
49
|
+
// scaffold
|
|
50
|
+
SCAFFOLD_CONFLICT: 'SCAFFOLD_CONFLICT',
|
|
51
|
+
SCAFFOLD_USAGE: 'SCAFFOLD_USAGE',
|
|
52
|
+
// settings / hooks (sh validator parity)
|
|
53
|
+
SETTINGS_SEC_1: 'SETTINGS_SEC_1',
|
|
54
|
+
SETTINGS_SEC_2: 'SETTINGS_SEC_2',
|
|
55
|
+
SETTINGS_SEC_3: 'SETTINGS_SEC_3',
|
|
56
|
+
SETTINGS_SEC_4: 'SETTINGS_SEC_4',
|
|
57
|
+
SETTINGS_OPS_1: 'SETTINGS_OPS_1',
|
|
58
|
+
SETTINGS_OPS_2: 'SETTINGS_OPS_2',
|
|
59
|
+
// env / usage
|
|
60
|
+
ENV_REPO_ROOT_UNKNOWN: 'ENV_REPO_ROOT_UNKNOWN',
|
|
61
|
+
ENV_FACTS_MISSING: 'ENV_FACTS_MISSING',
|
|
62
|
+
USAGE_UNKNOWN_FLAG: 'USAGE_UNKNOWN_FLAG',
|
|
63
|
+
USAGE_MISSING_POSITIONAL: 'USAGE_MISSING_POSITIONAL',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Structured validator error. Extends `Error` so existing `throw`/`catch`
|
|
68
|
+
* paths continue to work; extra properties give consumers a machine-parseable
|
|
69
|
+
* view.
|
|
70
|
+
*/
|
|
71
|
+
export class ValidationError extends Error {
|
|
72
|
+
/**
|
|
73
|
+
* @param {StructuredError} details
|
|
74
|
+
*/
|
|
75
|
+
constructor(details) {
|
|
76
|
+
if (!details || typeof details !== 'object') {
|
|
77
|
+
throw new TypeError('ValidationError requires a StructuredError details object');
|
|
78
|
+
}
|
|
79
|
+
if (!details.code || typeof details.code !== 'string') {
|
|
80
|
+
throw new TypeError('ValidationError requires a non-empty `code`');
|
|
81
|
+
}
|
|
82
|
+
if (!details.message || typeof details.message !== 'string') {
|
|
83
|
+
throw new TypeError('ValidationError requires a non-empty `message`');
|
|
84
|
+
}
|
|
85
|
+
super(details.message);
|
|
86
|
+
this.name = 'ValidationError';
|
|
87
|
+
this.code = details.code;
|
|
88
|
+
if (details.file !== undefined) this.file = details.file;
|
|
89
|
+
if (details.pointer !== undefined) this.pointer = details.pointer;
|
|
90
|
+
if (details.line !== undefined) this.line = details.line;
|
|
91
|
+
if (details.expected !== undefined) this.expected = details.expected;
|
|
92
|
+
if (details.got !== undefined) this.got = details.got;
|
|
93
|
+
if (details.hint !== undefined) this.hint = details.hint;
|
|
94
|
+
if (details.category !== undefined) this.category = details.category;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Legacy string format: `"<file>: <message>"` so existing tests and CI
|
|
99
|
+
* pipelines that regex on stderr continue to work unchanged.
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
toString() {
|
|
103
|
+
return this.file ? `${this.file}: ${this.message}` : this.message;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* JSON-safe representation for `--json` output.
|
|
108
|
+
* @returns {StructuredError}
|
|
109
|
+
*/
|
|
110
|
+
toJSON() {
|
|
111
|
+
/** @type {StructuredError} */
|
|
112
|
+
const out = { code: this.code, message: this.message };
|
|
113
|
+
if (this.file !== undefined) out.file = this.file;
|
|
114
|
+
if (this.pointer !== undefined) out.pointer = this.pointer;
|
|
115
|
+
if (this.line !== undefined) out.line = this.line;
|
|
116
|
+
if (this.expected !== undefined) out.expected = this.expected;
|
|
117
|
+
if (this.got !== undefined) out.got = this.got;
|
|
118
|
+
if (this.hint !== undefined) out.hint = this.hint;
|
|
119
|
+
if (this.category !== undefined) out.category = this.category;
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Render a single error for human-readable CLI output. Mirrors the
|
|
126
|
+
* `✗ <message>` prefix style used by `plugins/harness/scripts/validate-settings.sh:43-45`.
|
|
127
|
+
*
|
|
128
|
+
* @param {ValidationError | StructuredError | Error} err
|
|
129
|
+
* @param {{ verbose?: boolean }} [opts]
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
export function formatError(err, opts = {}) {
|
|
133
|
+
const verbose = Boolean(opts.verbose);
|
|
134
|
+
const prefix = err.file ? `${err.file}: ` : '';
|
|
135
|
+
const head = `${prefix}${err.message ?? String(err)}`;
|
|
136
|
+
if (!verbose) return head;
|
|
137
|
+
|
|
138
|
+
const tail = [];
|
|
139
|
+
if (err.code) tail.push(` code: ${err.code}`);
|
|
140
|
+
if (err.pointer !== undefined) tail.push(` pointer: ${err.pointer}`);
|
|
141
|
+
if (err.line !== undefined) tail.push(` line: ${err.line}`);
|
|
142
|
+
if (err.expected !== undefined) tail.push(` expected: ${err.expected}`);
|
|
143
|
+
if (err.got !== undefined) tail.push(` got: ${err.got}`);
|
|
144
|
+
if (err.hint !== undefined) tail.push(` hint: ${err.hint}`);
|
|
145
|
+
if (err.category !== undefined) tail.push(` category: ${err.category}`);
|
|
146
|
+
return tail.length > 0 ? `${head}\n${tail.join('\n')}` : head;
|
|
147
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named exit code enum for every harness bin.
|
|
3
|
+
*
|
|
4
|
+
* Convention:
|
|
5
|
+
* - 0 OK — success
|
|
6
|
+
* - 1 VALIDATION — one or more validation rules failed (the expected failure mode)
|
|
7
|
+
* - 2 ENV — misconfigured environment (missing file, bad git repo, unreadable facts)
|
|
8
|
+
* - 64 USAGE — bad CLI invocation (unknown flag, missing required positional)
|
|
9
|
+
*
|
|
10
|
+
* 64 mirrors BSD sysexits.h EX_USAGE so operators can distinguish user error
|
|
11
|
+
* from a real validation failure in CI pipelines.
|
|
12
|
+
*/
|
|
13
|
+
export const EXIT_CODES = Object.freeze({
|
|
14
|
+
OK: 0,
|
|
15
|
+
VALIDATION: 1,
|
|
16
|
+
ENV: 2,
|
|
17
|
+
USAGE: 64,
|
|
18
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CLI output primitives — the JS parity of
|
|
3
|
+
* `plugins/harness/scripts/validate-settings.sh:43-45`.
|
|
4
|
+
*
|
|
5
|
+
* Provides the same ✓/✗/⚠ format with ANSI coloring when the stream is a TTY,
|
|
6
|
+
* plus a `--json` buffer-and-flush mode so CI pipelines can consume a single
|
|
7
|
+
* JSON array of events instead of regexing stderr.
|
|
8
|
+
*
|
|
9
|
+
* @typedef {'pass'|'fail'|'warn'|'info'} OutputKind
|
|
10
|
+
*
|
|
11
|
+
* @typedef {object} OutputEvent
|
|
12
|
+
* @property {OutputKind} kind
|
|
13
|
+
* @property {string} message
|
|
14
|
+
* @property {object} [details] Optional structured payload (e.g. `ValidationError.toJSON()`).
|
|
15
|
+
*
|
|
16
|
+
* @typedef {object} Output
|
|
17
|
+
* @property {(msg: string) => void} pass
|
|
18
|
+
* @property {(msg: string, details?: object) => void} fail
|
|
19
|
+
* @property {(msg: string, details?: object) => void} warn
|
|
20
|
+
* @property {(msg: string) => void} info
|
|
21
|
+
* @property {() => void} flush Emit buffered JSON when in json mode. No-op otherwise.
|
|
22
|
+
* @property {() => { fail: number, warn: number, pass: number }} counts
|
|
23
|
+
*
|
|
24
|
+
* @typedef {object} OutputOptions
|
|
25
|
+
* @property {boolean} [json] When true, buffer events and emit a JSON array on flush().
|
|
26
|
+
* @property {boolean} [noColor] When true, suppress ANSI escapes regardless of TTY.
|
|
27
|
+
* @property {NodeJS.WritableStream} [stream] Defaults to process.stdout.
|
|
28
|
+
* @property {NodeJS.ProcessEnv} [env] Defaults to process.env.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const GREEN = '\x1b[32m';
|
|
32
|
+
const RED = '\x1b[31m';
|
|
33
|
+
const YELLOW = '\x1b[33m';
|
|
34
|
+
const RESET = '\x1b[0m';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Construct an `Output` with the given behavior flags.
|
|
38
|
+
*
|
|
39
|
+
* @param {OutputOptions} [opts]
|
|
40
|
+
* @returns {Output}
|
|
41
|
+
*/
|
|
42
|
+
export function createOutput(opts = {}) {
|
|
43
|
+
const env = opts.env ?? process.env;
|
|
44
|
+
const stream = opts.stream ?? process.stdout;
|
|
45
|
+
const json = Boolean(opts.json);
|
|
46
|
+
const noColor = Boolean(opts.noColor) || 'NO_COLOR' in env;
|
|
47
|
+
const useAnsi = !json && !noColor && Boolean(stream.isTTY);
|
|
48
|
+
|
|
49
|
+
const counts = { pass: 0, fail: 0, warn: 0 };
|
|
50
|
+
/** @type {OutputEvent[]} */
|
|
51
|
+
const buffer = [];
|
|
52
|
+
|
|
53
|
+
const color = (code, text) => (useAnsi ? `${code}${text}${RESET}` : text);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {OutputKind} kind
|
|
57
|
+
* @param {string} message
|
|
58
|
+
* @param {object} [details]
|
|
59
|
+
*/
|
|
60
|
+
const emit = (kind, message, details) => {
|
|
61
|
+
if (kind === 'pass') counts.pass++;
|
|
62
|
+
else if (kind === 'fail') counts.fail++;
|
|
63
|
+
else if (kind === 'warn') counts.warn++;
|
|
64
|
+
if (json) {
|
|
65
|
+
/** @type {OutputEvent} */
|
|
66
|
+
const event = { kind, message };
|
|
67
|
+
if (details !== undefined) event.details = details;
|
|
68
|
+
buffer.push(event);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
let glyph;
|
|
72
|
+
if (kind === 'pass') glyph = color(GREEN, '✓');
|
|
73
|
+
else if (kind === 'fail') glyph = color(RED, '✗');
|
|
74
|
+
else if (kind === 'warn') glyph = color(YELLOW, '⚠');
|
|
75
|
+
else glyph = ' ';
|
|
76
|
+
stream.write(` ${glyph} ${message}\n`);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
pass: (msg) => emit('pass', msg),
|
|
81
|
+
fail: (msg, details) => emit('fail', msg, details),
|
|
82
|
+
warn: (msg, details) => emit('warn', msg, details),
|
|
83
|
+
info: (msg) => emit('info', msg),
|
|
84
|
+
flush: () => {
|
|
85
|
+
if (!json) return;
|
|
86
|
+
stream.write(JSON.stringify({ events: buffer, counts }, null, 2) + '\n');
|
|
87
|
+
},
|
|
88
|
+
counts: () => ({ ...counts }),
|
|
89
|
+
};
|
|
90
|
+
}
|