gherkin-cli 0.0.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/dist/cli.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ //#region src/cli.d.ts
3
+ declare function main(argv: string[]): Promise<void>;
4
+ //#endregion
5
+ export { main };
package/dist/cli.js ADDED
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env node
2
+ import { a as diffFeatures, i as GitError, n as parseFeatures, r as parseFeaturesAst, t as validateFeatures } from "./validate-BjVkCi7A.js";
3
+ import { Command, CommanderError } from "commander";
4
+
5
+ //#region src/output.ts
6
+ /** Line threshold above which a rendered TOON result is truncated (never JSON). */
7
+ const TRUNCATE_THRESHOLD = 50;
8
+ const INDENT = " ";
9
+ function isPlainObject(value) {
10
+ return typeof value === "object" && value !== null && !Array.isArray(value);
11
+ }
12
+ function isScalar(value) {
13
+ return value === null || value === void 0 || typeof value !== "object";
14
+ }
15
+ function isScalarArray(value) {
16
+ return Array.isArray(value) && value.every(isScalar);
17
+ }
18
+ function isObjectArray(value) {
19
+ return Array.isArray(value) && value.length > 0 && value.every(isPlainObject);
20
+ }
21
+ /** A tabular object array has only scalar or scalar-array fields — no nesting. */
22
+ function isSimpleObjectArray(value) {
23
+ return isObjectArray(value) && value.every((item) => Object.values(item).every((field) => isScalar(field) || isScalarArray(field)));
24
+ }
25
+ function needsQuote(text) {
26
+ return text === "" || /[,:\s[\]{}"]/.test(text);
27
+ }
28
+ /** Render a single scalar, quoting strings that carry commas/spaces/structure. */
29
+ function formatScalar(value) {
30
+ if (value === null || value === void 0) return "null";
31
+ if (typeof value === "boolean" || typeof value === "number") return String(value);
32
+ const text = String(value);
33
+ return needsQuote(text) ? `"${text.replace(/"/g, "\\\"")}"` : text;
34
+ }
35
+ function formatInlineArray(items) {
36
+ return `[${items.map(formatScalar).join(",")}]`;
37
+ }
38
+ function columnUnion(items) {
39
+ const cols = [];
40
+ for (const item of items) for (const key of Object.keys(item)) if (!cols.includes(key)) cols.push(key);
41
+ return cols;
42
+ }
43
+ function formatCell(value) {
44
+ if (isScalarArray(value)) return formatInlineArray(value);
45
+ if (value === void 0) return "";
46
+ return formatScalar(value);
47
+ }
48
+ function encodeObject(obj, indent, lines) {
49
+ const pad = INDENT.repeat(indent);
50
+ for (const [key, value] of Object.entries(obj)) if (Array.isArray(value)) if (isSimpleObjectArray(value)) {
51
+ const cols = columnUnion(value);
52
+ lines.push(`${pad}${key}[${value.length}]{${cols.join(",")}}:`);
53
+ const rowPad = INDENT.repeat(indent + 1);
54
+ for (const item of value) lines.push(rowPad + cols.map((col) => formatCell(item[col])).join(","));
55
+ } else if (isObjectArray(value)) {
56
+ lines.push(`${pad}${key}[${value.length}]:`);
57
+ for (const item of value) encodeListItem(item, indent + 1, lines);
58
+ } else lines.push(`${pad}${key}: ${formatInlineArray(value)}`);
59
+ else if (isPlainObject(value)) {
60
+ lines.push(`${pad}${key}:`);
61
+ encodeObject(value, indent + 1, lines);
62
+ } else lines.push(`${pad}${key}: ${formatScalar(value)}`);
63
+ }
64
+ function encodeListItem(item, indent, lines) {
65
+ const start = lines.length;
66
+ encodeObject(item, indent, lines);
67
+ const firstPad = INDENT.repeat(indent);
68
+ const markerPad = INDENT.repeat(indent - 1) + "- ";
69
+ const first = lines[start];
70
+ if (first !== void 0) lines[start] = markerPad + first.slice(firstPad.length);
71
+ }
72
+ /** Encode a plain data object as deterministic TOON. */
73
+ function encodeToon(data) {
74
+ const lines = [];
75
+ if (isPlainObject(data)) encodeObject(data, 0, lines);
76
+ else lines.push(formatScalar(data));
77
+ return lines.join("\n");
78
+ }
79
+ /** Render a result in the requested machine format. */
80
+ function render(data, format) {
81
+ return format === "json" ? JSON.stringify(data, null, 2) : encodeToon(data);
82
+ }
83
+ /** Truncate an over-long TOON result with a size hint; JSON is never truncated. */
84
+ function truncate(text, opts) {
85
+ if (opts.format === "json" || opts.full) return text;
86
+ const lines = text.split("\n");
87
+ if (lines.length <= TRUNCATE_THRESHOLD) return text;
88
+ const kept = lines.slice(0, TRUNCATE_THRESHOLD);
89
+ const remaining = lines.length - TRUNCATE_THRESHOLD;
90
+ kept.push(`… +${remaining} lines — rerun with --full`);
91
+ return kept.join("\n");
92
+ }
93
+ function writeLine(stream, text) {
94
+ stream.write(text.endsWith("\n") ? text : text + "\n");
95
+ }
96
+ /** Write the machine result to STDOUT (the only thing that goes there). */
97
+ function writeResult(text, out = process.stdout) {
98
+ writeLine(out, text);
99
+ }
100
+ /** Write a human affordance (next-step, warning) to STDERR. */
101
+ function writeStderr(text, err = process.stderr) {
102
+ writeLine(err, text);
103
+ }
104
+ /** Print a structured error to STDERR honoring the format, then exit 1. */
105
+ function fail(code, message, format = "toon", opts = {}) {
106
+ writeStderr(format === "json" ? JSON.stringify({ error: {
107
+ code,
108
+ message
109
+ } }, null, 2) : `error: true\ncode: ${code}\nmessage: ${formatScalar(message)}`, opts.err);
110
+ (opts.exit ?? process.exit)(1);
111
+ }
112
+
113
+ //#endregion
114
+ //#region src/cli.ts
115
+ function resolveFormat(raw) {
116
+ return raw === "json" ? "json" : "toon";
117
+ }
118
+ /** Best-effort format read for the error path, before commander has parsed. */
119
+ function preScanFormat(argv) {
120
+ const i = argv.indexOf("--format");
121
+ return i >= 0 && argv[i + 1] === "json" ? "json" : "toon";
122
+ }
123
+ function emit(data, format, full) {
124
+ writeResult(truncate(render(data, format), {
125
+ full,
126
+ format
127
+ }));
128
+ }
129
+ function addFormat(cmd) {
130
+ return cmd.option("--format <fmt>", "output format: toon | json", "toon");
131
+ }
132
+ function buildProgram() {
133
+ const program = new Command();
134
+ program.name("gherkin-cli").description("Agent-first Gherkin CLI — parse, validate, and diff .feature files.").option("--format <fmt>", "output format: toon | json", "toon");
135
+ addFormat(program.command("parse").description("Project .feature files to a token-efficient summary").argument("<files...>", ".feature files to parse").option("--full", "include stepCount, exampleRows, and steps").option("--tag <name>", "keep only scenarios carrying this tag").option("--ast", "dump the raw GherkinDocument JSON (ignores projection)")).addHelpText("after", "\nExample:\n $ gherkin-cli parse features/login.feature --tag @smoke").action((files, opts, command) => {
136
+ const format = resolveFormat(command.optsWithGlobals().format);
137
+ const full = Boolean(opts.full);
138
+ if (opts.ast) {
139
+ const ast = parseFeaturesAst(files);
140
+ const missing$1 = ast.find((f) => f.error?.code === "ENOENT");
141
+ if (missing$1) return fail("ENOENT", `file not found: ${missing$1.file}`, format);
142
+ return writeResult(render(ast, "json"));
143
+ }
144
+ const result = parseFeatures(files, {
145
+ full,
146
+ tag: opts.tag
147
+ });
148
+ const missing = result.files.find((f) => f.error?.code === "ENOENT");
149
+ if (missing) return fail("ENOENT", `file not found: ${missing.file}`, format);
150
+ emit(result, format, full);
151
+ if (result.summary.files === 0 || result.summary.scenarios === 0) writeStderr(`0 scenarios across ${result.summary.files} files`);
152
+ writeStderr(`→ gherkin-cli diff --base <ref> ${files.join(" ")}`);
153
+ });
154
+ addFormat(program.command("validate").description("Validate .feature syntax (the CI/gate verb)").argument("<files...>", ".feature files to validate")).addHelpText("after", "\nExample:\n $ gherkin-cli validate features/login.feature").action((files, _opts, command) => {
155
+ const format = resolveFormat(command.optsWithGlobals().format);
156
+ const result = validateFeatures(files);
157
+ emit(result, format, false);
158
+ if (result.summary.errors === 0) {
159
+ writeStderr("0 errors");
160
+ writeStderr(`→ gherkin-cli parse ${files.join(" ")}`);
161
+ return;
162
+ }
163
+ writeStderr(`→ ${result.summary.errors} error(s) across ${result.summary.files} file(s)`);
164
+ process.exit(1);
165
+ });
166
+ addFormat(program.command("diff").description("Classify scenario changes against a base git ref").argument("<files...>", ".feature files to diff").requiredOption("--base <ref>", "base git ref to compare against").option("--full", "include unchanged scenarios in full detail")).addHelpText("after", "\nExample:\n $ gherkin-cli diff features/login.feature --base HEAD~1").action((files, opts, command) => {
167
+ const format = resolveFormat(command.optsWithGlobals().format);
168
+ const full = Boolean(opts.full);
169
+ let result;
170
+ try {
171
+ result = diffFeatures(files, { base: opts.base });
172
+ } catch (err) {
173
+ if (err instanceof GitError) return fail("EGIT", err.message, format);
174
+ throw err;
175
+ }
176
+ emit(result, format, full);
177
+ const { added, modified, removed } = result.summary;
178
+ if (added + modified + removed === 0) {
179
+ writeStderr("0 changes (all unchanged)");
180
+ writeStderr(`→ gherkin-cli parse ${files.join(" ")}`);
181
+ return;
182
+ }
183
+ writeStderr(`→ review ${added} added / ${modified} modified / ${removed} removed scenario(s)`);
184
+ });
185
+ return program;
186
+ }
187
+ async function main(argv) {
188
+ const program = buildProgram();
189
+ const silence = { writeErr: () => {} };
190
+ program.exitOverride().configureOutput(silence);
191
+ for (const cmd of program.commands) cmd.exitOverride().configureOutput(silence);
192
+ if (argv.length === 0) {
193
+ writeStderr(program.helpInformation());
194
+ return;
195
+ }
196
+ try {
197
+ await program.parseAsync(argv, { from: "user" });
198
+ } catch (err) {
199
+ if (err instanceof CommanderError) {
200
+ if (err.code === "commander.helpDisplayed" || err.code === "commander.help" || err.code === "commander.version") return;
201
+ return fail("EBADFLAG", err.message, preScanFormat(argv));
202
+ }
203
+ throw err;
204
+ }
205
+ }
206
+ /* c8 ignore start */
207
+ if (process.argv[1]?.endsWith("cli.js") || process.argv[1]?.endsWith("cli.ts")) main(process.argv.slice(2)).catch((err) => {
208
+ writeStderr(`error: ${err.message}`);
209
+ process.exit(1);
210
+ });
211
+ /* c8 ignore stop */
212
+
213
+ //#endregion
214
+ export { main };
@@ -0,0 +1,115 @@
1
+ //#region src/diff.d.ts
2
+ type ChangeKind = 'added' | 'modified' | 'removed' | 'unchanged';
3
+ interface DiffScenario {
4
+ name: string;
5
+ change: ChangeKind;
6
+ }
7
+ interface DiffFileError {
8
+ code: 'EGIT' | 'EPARSE' | 'ENOENT';
9
+ message: string;
10
+ }
11
+ interface DiffFile {
12
+ file: string;
13
+ addOnly: boolean;
14
+ scenarios: DiffScenario[];
15
+ error?: DiffFileError;
16
+ }
17
+ interface DiffResult {
18
+ summary: {
19
+ added: number;
20
+ modified: number;
21
+ removed: number;
22
+ unchanged: number;
23
+ files: number;
24
+ addOnly: boolean;
25
+ };
26
+ files: DiffFile[];
27
+ }
28
+ /** Reads a file's working-tree and base-ref text. Injectable so tests can skip git. */
29
+ type DiffReader = (file: string, base: string) => {
30
+ head?: string;
31
+ base?: string;
32
+ };
33
+ interface DiffOptions {
34
+ base: string;
35
+ reader?: DiffReader;
36
+ }
37
+ /** Thrown for genuine git/ref failures so the CLI can map it to `fail('EGIT', …)`. */
38
+ declare class GitError extends Error {
39
+ constructor(message: string);
40
+ }
41
+ /**
42
+ * Classify each file's scenarios against its `--base` version. Git/ref failures
43
+ * surface as a thrown `GitError` for the CLI to convert into `fail('EGIT', …)`;
44
+ * parse failures surface as a per-file `error` field (no throw).
45
+ */
46
+ declare function diffFeatures(paths: string[], opts: DiffOptions): DiffResult;
47
+ //#endregion
48
+ //#region src/parse.d.ts
49
+ interface ParseStep {
50
+ keyword: string;
51
+ text: string;
52
+ }
53
+ interface ParseScenario {
54
+ name: string;
55
+ keyword: string;
56
+ tags: string[];
57
+ stepCount?: number;
58
+ exampleRows?: number;
59
+ steps?: ParseStep[];
60
+ }
61
+ interface ParseFileError {
62
+ code: 'EPARSE' | 'ENOENT';
63
+ line: number;
64
+ message: string;
65
+ }
66
+ interface ParseFile {
67
+ file: string;
68
+ featureTags: string[];
69
+ scenarioCount: number;
70
+ sectionComments: number;
71
+ scenarios: ParseScenario[];
72
+ error?: ParseFileError;
73
+ }
74
+ interface ParseResult {
75
+ summary: {
76
+ files: number;
77
+ scenarios: number;
78
+ errors: number;
79
+ };
80
+ files: ParseFile[];
81
+ }
82
+ interface ParseOptions {
83
+ full?: boolean;
84
+ tag?: string;
85
+ }
86
+ /**
87
+ * Project each `.feature` file to the `parse` data shape. Pure: a missing or
88
+ * malformed file becomes an `error` entry rather than throwing — the CLI layer
89
+ * decides the exit code (ENOENT is a hard fail; EPARSE is best-effort, exit 0).
90
+ */
91
+ declare function parseFeatures(paths: string[], opts?: ParseOptions): ParseResult;
92
+ //#endregion
93
+ //#region src/validate.d.ts
94
+ interface ValidateError {
95
+ line: number;
96
+ message: string;
97
+ code: 'EPARSE' | 'ENOENT';
98
+ }
99
+ interface ValidateFile {
100
+ file: string;
101
+ ok: boolean;
102
+ errors: ValidateError[];
103
+ }
104
+ interface ValidateResult {
105
+ summary: {
106
+ files: number;
107
+ errors: number;
108
+ };
109
+ files: ValidateFile[];
110
+ }
111
+ type ValidateOptions = Record<string, never>;
112
+ /** Parse each file and collect syntax errors. The CLI exits 1 if any file is invalid. */
113
+ declare function validateFeatures(paths: string[], _opts?: ValidateOptions): ValidateResult;
114
+ //#endregion
115
+ export { type ChangeKind, type DiffFile, type DiffFileError, type DiffOptions, type DiffReader, type DiffResult, type DiffScenario, GitError, type ParseFile, type ParseFileError, type ParseOptions, type ParseResult, type ParseScenario, type ParseStep, type ValidateError, type ValidateFile, type ValidateOptions, type ValidateResult, diffFeatures, parseFeatures, validateFeatures };
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import { a as diffFeatures, i as GitError, n as parseFeatures, t as validateFeatures } from "./validate-BjVkCi7A.js";
2
+
3
+ export { GitError, diffFeatures, parseFeatures, validateFeatures };
@@ -0,0 +1,370 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { AstBuilder, GherkinClassicTokenMatcher, Parser } from "@cucumber/gherkin";
5
+ import { IdGenerator } from "@cucumber/messages";
6
+
7
+ //#region src/diff.ts
8
+ /** Thrown for genuine git/ref failures so the CLI can map it to `fail('EGIT', …)`. */
9
+ var GitError = class extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "GitError";
13
+ }
14
+ };
15
+ function newParser$2() {
16
+ return new Parser(new AstBuilder(IdGenerator.incrementing()), new GherkinClassicTokenMatcher());
17
+ }
18
+ /** Default reader: working-tree text from fs, base text from `git show <ref>:<relpath>`. */
19
+ function gitReader(file, base) {
20
+ const abs = path.resolve(file);
21
+ const dir = path.dirname(abs);
22
+ let head;
23
+ try {
24
+ head = readFileSync(abs, "utf8");
25
+ } catch {
26
+ head = void 0;
27
+ }
28
+ const gitIo = {
29
+ cwd: dir,
30
+ encoding: "utf8",
31
+ stdio: [
32
+ "ignore",
33
+ "pipe",
34
+ "ignore"
35
+ ]
36
+ };
37
+ let rel;
38
+ try {
39
+ rel = execFileSync("git", [
40
+ "ls-files",
41
+ "--full-name",
42
+ "--",
43
+ abs
44
+ ], gitIo).trim();
45
+ } catch (err) {
46
+ throw new GitError(`git ls-files failed for ${file}: ${err.message}`);
47
+ }
48
+ if (rel === "") {
49
+ const top = execFileSync("git", ["rev-parse", "--show-toplevel"], gitIo).trim();
50
+ rel = path.relative(top, abs).split(path.sep).join("/");
51
+ }
52
+ let baseText;
53
+ try {
54
+ baseText = execFileSync("git", ["show", `${base}:${rel}`], gitIo);
55
+ } catch {
56
+ try {
57
+ execFileSync("git", [
58
+ "rev-parse",
59
+ "--verify",
60
+ "--quiet",
61
+ `${base}^{commit}`
62
+ ], {
63
+ cwd: dir,
64
+ stdio: "ignore"
65
+ });
66
+ baseText = void 0;
67
+ } catch {
68
+ throw new GitError(`git could not resolve base ref '${base}'`);
69
+ }
70
+ }
71
+ return {
72
+ head,
73
+ base: baseText
74
+ };
75
+ }
76
+ /** Canonical signature of a scenario: name + steps + tags + examples. */
77
+ function signature(scenario) {
78
+ const steps = (scenario.steps ?? []).map((s) => `${s.keyword.trim()} ${s.text}`);
79
+ const tags = (scenario.tags ?? []).map((t) => t.name);
80
+ const examples = (scenario.examples ?? []).map((ex) => ({
81
+ header: ex.tableHeader?.cells.map((c) => c.value) ?? [],
82
+ body: (ex.tableBody ?? []).map((r) => r.cells.map((c) => c.value))
83
+ }));
84
+ return JSON.stringify({
85
+ steps,
86
+ tags,
87
+ examples
88
+ });
89
+ }
90
+ /** Map scenario name → signature, in document order. Returns null on parse failure. */
91
+ function scenarioMap(text) {
92
+ if (text === void 0) return /* @__PURE__ */ new Map();
93
+ let doc;
94
+ try {
95
+ doc = newParser$2().parse(text);
96
+ } catch {
97
+ return null;
98
+ }
99
+ const map = /* @__PURE__ */ new Map();
100
+ for (const child of doc.feature?.children ?? []) if (child.scenario) map.set(child.scenario.name, signature(child.scenario));
101
+ return map;
102
+ }
103
+ function classifyFile(file, head, base) {
104
+ const headMap = scenarioMap(head);
105
+ const baseMap = scenarioMap(base);
106
+ if (headMap === null || baseMap === null) return {
107
+ file,
108
+ addOnly: false,
109
+ scenarios: [],
110
+ error: {
111
+ code: "EPARSE",
112
+ message: "failed to parse feature"
113
+ }
114
+ };
115
+ const scenarios = [];
116
+ for (const [name, headSig] of headMap) if (!baseMap.has(name)) scenarios.push({
117
+ name,
118
+ change: "added"
119
+ });
120
+ else if (baseMap.get(name) !== headSig) scenarios.push({
121
+ name,
122
+ change: "modified"
123
+ });
124
+ else scenarios.push({
125
+ name,
126
+ change: "unchanged"
127
+ });
128
+ for (const name of baseMap.keys()) if (!headMap.has(name)) scenarios.push({
129
+ name,
130
+ change: "removed"
131
+ });
132
+ return {
133
+ file,
134
+ addOnly: !scenarios.some((s) => s.change === "modified" || s.change === "removed"),
135
+ scenarios
136
+ };
137
+ }
138
+ /**
139
+ * Classify each file's scenarios against its `--base` version. Git/ref failures
140
+ * surface as a thrown `GitError` for the CLI to convert into `fail('EGIT', …)`;
141
+ * parse failures surface as a per-file `error` field (no throw).
142
+ */
143
+ function diffFeatures(paths, opts) {
144
+ const reader = opts.reader ?? gitReader;
145
+ const files = paths.map((file) => {
146
+ const { head, base } = reader(file, opts.base);
147
+ return classifyFile(file, head, base);
148
+ });
149
+ const summary = {
150
+ added: 0,
151
+ modified: 0,
152
+ removed: 0,
153
+ unchanged: 0,
154
+ files: files.length,
155
+ addOnly: true
156
+ };
157
+ for (const f of files) for (const s of f.scenarios) summary[s.change] += 1;
158
+ summary.addOnly = summary.modified === 0 && summary.removed === 0;
159
+ return {
160
+ summary,
161
+ files
162
+ };
163
+ }
164
+
165
+ //#endregion
166
+ //#region src/parse.ts
167
+ /** Build a fresh parser with a deterministic (incrementing) id generator. */
168
+ function newParser$1() {
169
+ return new Parser(new AstBuilder(IdGenerator.incrementing()), new GherkinClassicTokenMatcher());
170
+ }
171
+ /** A comment counts as a "section" rule if it draws a box-drawing rule or a 3+ run of -/=. */
172
+ function isSectionComment(text) {
173
+ const body = text.replace(/^\s*#/, "");
174
+ return /[─-╿]/.test(body) || /([-=])\1{2,}/.test(body);
175
+ }
176
+ function normalizeTag(tag) {
177
+ return tag.replace(/^@/, "");
178
+ }
179
+ function firstErrorLine(err) {
180
+ return err.location?.line ?? 0;
181
+ }
182
+ function errorMessage(err) {
183
+ return err.message.split("\n")[0] ?? err.message;
184
+ }
185
+ function projectDoc(doc, opts) {
186
+ const feature = doc.feature;
187
+ const featureTags = (feature?.tags ?? []).map((t) => t.name);
188
+ const sectionComments = (doc.comments ?? []).filter((c) => isSectionComment(c.text)).length;
189
+ const scenarios = [];
190
+ for (const child of feature?.children ?? []) {
191
+ const scenario = child.scenario;
192
+ if (!scenario) continue;
193
+ const tags = (scenario.tags ?? []).map((t) => t.name);
194
+ if (opts.tag !== void 0) {
195
+ const wanted = normalizeTag(opts.tag);
196
+ if (!tags.some((t) => normalizeTag(t) === wanted)) continue;
197
+ }
198
+ const steps = (scenario.steps ?? []).map((s) => ({
199
+ keyword: s.keyword.trim(),
200
+ text: s.text
201
+ }));
202
+ const exampleRows = (scenario.examples ?? []).reduce((sum, ex) => sum + (ex.tableBody?.length ?? 0), 0);
203
+ const projected = {
204
+ name: scenario.name,
205
+ keyword: scenario.keyword,
206
+ tags
207
+ };
208
+ if (opts.full) {
209
+ projected.stepCount = steps.length;
210
+ projected.exampleRows = exampleRows;
211
+ projected.steps = steps;
212
+ }
213
+ scenarios.push(projected);
214
+ }
215
+ return {
216
+ featureTags,
217
+ scenarios,
218
+ sectionComments
219
+ };
220
+ }
221
+ function parseOne(path$1, opts) {
222
+ let text;
223
+ try {
224
+ text = readFileSync(path$1, "utf8");
225
+ } catch (err) {
226
+ return {
227
+ file: path$1,
228
+ featureTags: [],
229
+ scenarioCount: 0,
230
+ sectionComments: 0,
231
+ scenarios: [],
232
+ error: {
233
+ code: "ENOENT",
234
+ line: 0,
235
+ message: err.message
236
+ }
237
+ };
238
+ }
239
+ try {
240
+ const { featureTags, scenarios, sectionComments } = projectDoc(newParser$1().parse(text), opts);
241
+ return {
242
+ file: path$1,
243
+ featureTags,
244
+ scenarioCount: scenarios.length,
245
+ sectionComments,
246
+ scenarios
247
+ };
248
+ } catch (err) {
249
+ const composite = err;
250
+ const first = composite.errors?.[0] ?? composite;
251
+ return {
252
+ file: path$1,
253
+ featureTags: [],
254
+ scenarioCount: 0,
255
+ sectionComments: 0,
256
+ scenarios: [],
257
+ error: {
258
+ code: "EPARSE",
259
+ line: firstErrorLine(first),
260
+ message: errorMessage(first)
261
+ }
262
+ };
263
+ }
264
+ }
265
+ /**
266
+ * Project each `.feature` file to the `parse` data shape. Pure: a missing or
267
+ * malformed file becomes an `error` entry rather than throwing — the CLI layer
268
+ * decides the exit code (ENOENT is a hard fail; EPARSE is best-effort, exit 0).
269
+ */
270
+ function parseFeatures(paths, opts = {}) {
271
+ const files = paths.map((path$1) => parseOne(path$1, opts));
272
+ return {
273
+ summary: {
274
+ files: files.length,
275
+ scenarios: files.reduce((sum, f) => sum + f.scenarioCount, 0),
276
+ errors: files.filter((f) => f.error).length
277
+ },
278
+ files
279
+ };
280
+ }
281
+ /** Dump the raw GherkinDocument for each file (backs `parse --ast`). */
282
+ function parseFeaturesAst(paths) {
283
+ return paths.map((path$1) => {
284
+ let text;
285
+ try {
286
+ text = readFileSync(path$1, "utf8");
287
+ } catch (err) {
288
+ return {
289
+ file: path$1,
290
+ error: {
291
+ code: "ENOENT",
292
+ line: 0,
293
+ message: err.message
294
+ }
295
+ };
296
+ }
297
+ try {
298
+ return {
299
+ file: path$1,
300
+ ast: newParser$1().parse(text)
301
+ };
302
+ } catch (err) {
303
+ const composite = err;
304
+ const first = composite.errors?.[0] ?? composite;
305
+ return {
306
+ file: path$1,
307
+ error: {
308
+ code: "EPARSE",
309
+ line: firstErrorLine(first),
310
+ message: errorMessage(first)
311
+ }
312
+ };
313
+ }
314
+ });
315
+ }
316
+
317
+ //#endregion
318
+ //#region src/validate.ts
319
+ function newParser() {
320
+ return new Parser(new AstBuilder(IdGenerator.incrementing()), new GherkinClassicTokenMatcher());
321
+ }
322
+ function validateOne(path$1) {
323
+ let text;
324
+ try {
325
+ text = readFileSync(path$1, "utf8");
326
+ } catch (err) {
327
+ return {
328
+ file: path$1,
329
+ ok: false,
330
+ errors: [{
331
+ line: 0,
332
+ message: err.message,
333
+ code: "ENOENT"
334
+ }]
335
+ };
336
+ }
337
+ try {
338
+ newParser().parse(text);
339
+ return {
340
+ file: path$1,
341
+ ok: true,
342
+ errors: []
343
+ };
344
+ } catch (err) {
345
+ const composite = err;
346
+ return {
347
+ file: path$1,
348
+ ok: false,
349
+ errors: (composite.errors ?? [composite]).map((e) => ({
350
+ line: e.location?.line ?? 0,
351
+ message: e.message.split("\n")[0] ?? e.message,
352
+ code: "EPARSE"
353
+ }))
354
+ };
355
+ }
356
+ }
357
+ /** Parse each file and collect syntax errors. The CLI exits 1 if any file is invalid. */
358
+ function validateFeatures(paths, _opts = {}) {
359
+ const files = paths.map(validateOne);
360
+ return {
361
+ summary: {
362
+ files: files.length,
363
+ errors: files.reduce((sum, f) => sum + f.errors.length, 0)
364
+ },
365
+ files
366
+ };
367
+ }
368
+
369
+ //#endregion
370
+ export { diffFeatures as a, GitError as i, parseFeatures as n, parseFeaturesAst as r, validateFeatures as t };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "gherkin-cli",
3
+ "version": "0.0.0",
4
+ "description": "Agent-first Gherkin CLI — parse, validate, and diff .feature files with token-efficient, AXI-conformant output.",
5
+ "keywords": [
6
+ "gherkin",
7
+ "cucumber",
8
+ "feature",
9
+ "cli",
10
+ "agent",
11
+ "axi",
12
+ "toon"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/cyberuni/gherkin-cli.git"
17
+ },
18
+ "author": "unional <homawong@gmail.com>",
19
+ "license": "MIT",
20
+ "type": "module",
21
+ "files": [
22
+ "dist",
23
+ "!dist/**/*.test.*"
24
+ ],
25
+ "exports": {
26
+ ".": {
27
+ "import": "./dist/index.js",
28
+ "types": "./dist/index.d.ts"
29
+ }
30
+ },
31
+ "bin": {
32
+ "gherkin-cli": "dist/cli.js"
33
+ },
34
+ "scripts": {
35
+ "build": "tsdown",
36
+ "dev": "tsx src/cli.ts",
37
+ "format": "biome format --write .",
38
+ "test": "vitest run src",
39
+ "test:watch": "vitest",
40
+ "typecheck": "tsc --noEmit"
41
+ },
42
+ "dependencies": {
43
+ "@cucumber/gherkin": "^41.0.0",
44
+ "@cucumber/messages": "^33.0.0",
45
+ "commander": "^14.0.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^25.9.1",
49
+ "tsdown": "^0.16.6",
50
+ "tsx": "^4.19.4",
51
+ "typescript": "^6.0.3",
52
+ "vitest": "^4.1.7"
53
+ },
54
+ "engines": {
55
+ "node": ">=22"
56
+ }
57
+ }