@bantay/cli 0.1.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bantay/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Write down the rules your system must never break. We enforce them on every PR.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -12,7 +12,9 @@ import {
12
12
  handleAideLock,
13
13
  printAideHelp,
14
14
  } from "./commands/aide";
15
- import { exportInvariants, exportClaude, exportCursor, exportAll } from "./export";
15
+ import { exportInvariants, exportClaude, exportCursor, exportCodex, exportAll } from "./export";
16
+ import { runStatus, formatStatus } from "./commands/status";
17
+ import { runCi, type CiOptions } from "./commands/ci";
16
18
 
17
19
  const args = process.argv.slice(2);
18
20
  const command = args[0];
@@ -33,10 +35,11 @@ async function main() {
33
35
  } else if (command === "aide") {
34
36
  await handleAide(args.slice(1));
35
37
  } else if (command === "ci") {
36
- console.error("bantay ci: Not yet implemented");
37
- process.exit(1);
38
+ await handleCi(args.slice(1));
38
39
  } else if (command === "export") {
39
40
  await handleExport(args.slice(1));
41
+ } else if (command === "status") {
42
+ await handleStatus(args.slice(1));
40
43
  } else {
41
44
  console.error(`Unknown command: ${command}`);
42
45
  console.error('Run "bantay help" for usage information.');
@@ -72,6 +75,7 @@ Commands:
72
75
  aide Manage the aide entity tree (add, remove, link, show, validate, lock)
73
76
  ci Generate CI workflow configuration
74
77
  export Export invariants to agent context files
78
+ status Show scenario implementation status
75
79
 
76
80
  Options:
77
81
  -h, --help Show this help message
@@ -83,10 +87,12 @@ Examples:
83
87
  bantay aide show Show the aide entity tree
84
88
  bantay aide add inv_test --parent invariants --prop "statement=Test"
85
89
  bantay ci --github-actions Generate GitHub Actions workflow
86
- bantay export all Export all targets
87
- bantay export invariants Generate invariants.md from bantay.aide
88
- bantay export claude Export to CLAUDE.md
89
- bantay export cursor Export to .cursorrules
90
+ bantay export all Export all targets
91
+ bantay export invariants Generate invariants.md from bantay.aide
92
+ bantay export claude Export to CLAUDE.md
93
+ bantay export cursor Export to .cursorrules
94
+ bantay status Show scenario implementation status
95
+ bantay status --json Output as JSON
90
96
 
91
97
  Run "bantay aide help" for aide subcommand details.
92
98
  `);
@@ -133,6 +139,12 @@ async function handleInit(args: string[]) {
133
139
  console.log(" Auth: Not detected");
134
140
  }
135
141
 
142
+ if (result.detection.payments) {
143
+ console.log(` Payments: ${result.detection.payments.name} (${result.detection.payments.confidence} confidence)`);
144
+ } else {
145
+ console.log(" Payments: Not detected");
146
+ }
147
+
136
148
  console.log("");
137
149
 
138
150
  // Display warnings
@@ -223,14 +235,16 @@ async function handleExport(args: string[]) {
223
235
  console.error("Usage: bantay export <target>");
224
236
  console.error("");
225
237
  console.error("Targets:");
226
- console.error(" all Export all targets (invariants, claude, cursor)");
238
+ console.error(" all Export all targets (invariants, claude, cursor, codex)");
227
239
  console.error(" invariants Generate invariants.md from bantay.aide");
228
240
  console.error(" claude Export to CLAUDE.md with section markers");
229
241
  console.error(" cursor Export to .cursorrules with section markers");
242
+ console.error(" codex Export to AGENTS.md with section markers");
230
243
  console.error("");
231
244
  console.error("Examples:");
232
245
  console.error(" bantay export invariants");
233
246
  console.error(" bantay export claude");
247
+ console.error(" bantay export codex");
234
248
  console.error(" bantay export --target cursor");
235
249
  process.exit(1);
236
250
  }
@@ -256,9 +270,13 @@ async function handleExport(args: string[]) {
256
270
  const result = await exportCursor(projectPath, { dryRun });
257
271
  console.log(`Exported to ${result.outputPath}`);
258
272
  console.log(` ${result.bytesWritten} bytes written`);
273
+ } else if (target === "codex") {
274
+ const result = await exportCodex(projectPath, { dryRun });
275
+ console.log(`Exported to ${result.outputPath}`);
276
+ console.log(` ${result.bytesWritten} bytes written`);
259
277
  } else {
260
278
  console.error(`Unknown export target: ${target}`);
261
- console.error('Valid targets: all, invariants, claude, cursor');
279
+ console.error('Valid targets: all, invariants, claude, cursor, codex');
262
280
  process.exit(1);
263
281
  }
264
282
 
@@ -277,6 +295,74 @@ async function handleExport(args: string[]) {
277
295
  }
278
296
  }
279
297
 
298
+ async function handleCi(args: string[]) {
299
+ const projectPath = process.cwd();
300
+
301
+ // Parse provider from args
302
+ const hasGitHub = args.includes("--github-actions") || args.includes("--github");
303
+ const hasGitLab = args.includes("--gitlab");
304
+ const force = args.includes("--force");
305
+
306
+ let provider: "github-actions" | "gitlab" | "generic";
307
+
308
+ if (hasGitHub) {
309
+ provider = "github-actions";
310
+ } else if (hasGitLab) {
311
+ provider = "gitlab";
312
+ } else {
313
+ provider = "generic";
314
+ }
315
+
316
+ try {
317
+ const result = await runCi(projectPath, { provider, force });
318
+
319
+ if (result.alreadyExists) {
320
+ console.error(`${result.outputPath} already exists.`);
321
+ console.error("Use --force to overwrite.");
322
+ process.exit(1);
323
+ }
324
+
325
+ if (provider === "generic") {
326
+ console.log(result.content);
327
+ } else {
328
+ console.log(`Generated ${result.outputPath}`);
329
+ }
330
+
331
+ process.exit(0);
332
+ } catch (error) {
333
+ if (error instanceof Error) {
334
+ console.error(`Error: ${error.message}`);
335
+ } else {
336
+ console.error("Error running ci:", error);
337
+ }
338
+ process.exit(1);
339
+ }
340
+ }
341
+
342
+ async function handleStatus(args: string[]) {
343
+ const projectPath = process.cwd();
344
+ const jsonOutput = args.includes("--json");
345
+
346
+ try {
347
+ const result = await runStatus(projectPath, { json: jsonOutput });
348
+
349
+ if (jsonOutput) {
350
+ console.log(JSON.stringify(result, null, 2));
351
+ } else {
352
+ console.log(formatStatus(result));
353
+ }
354
+
355
+ process.exit(0);
356
+ } catch (error) {
357
+ if (error instanceof Error) {
358
+ console.error(`Error: ${error.message}`);
359
+ } else {
360
+ console.error("Error running status:", error);
361
+ }
362
+ process.exit(1);
363
+ }
364
+ }
365
+
280
366
  async function handleAide(args: string[]) {
281
367
  const subcommand = args[0];
282
368
 
@@ -0,0 +1,228 @@
1
+ /**
2
+ * CI workflow generator
3
+ *
4
+ * Generates CI configuration for GitHub Actions, GitLab CI, or generic shell commands.
5
+ */
6
+
7
+ import { readFile, writeFile, access, mkdir } from "fs/promises";
8
+ import { join, dirname } from "path";
9
+
10
+ export interface CiOptions {
11
+ provider: "github-actions" | "gitlab" | "generic";
12
+ force?: boolean;
13
+ }
14
+
15
+ export interface CiResult {
16
+ provider: string;
17
+ outputPath?: string;
18
+ content: string;
19
+ alreadyExists?: boolean;
20
+ }
21
+
22
+ async function fileExists(path: string): Promise<boolean> {
23
+ try {
24
+ await access(path);
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Generate GitHub Actions workflow YAML
33
+ */
34
+ function generateGitHubWorkflow(): string {
35
+ return `name: Bantay Invariant Check
36
+
37
+ on:
38
+ pull_request:
39
+ branches: [main, master]
40
+ push:
41
+ branches: [main, master]
42
+
43
+ jobs:
44
+ bantay-check:
45
+ runs-on: ubuntu-latest
46
+ steps:
47
+ - uses: actions/checkout@v4
48
+
49
+ - name: Setup Bun
50
+ uses: oven-sh/setup-bun@v1
51
+ with:
52
+ bun-version: latest
53
+
54
+ - name: Install dependencies
55
+ run: bun install
56
+
57
+ - name: Run Bantay check
58
+ run: bunx @bantay/cli check --json > bantay-results.json 2>&1 || true
59
+
60
+ - name: Upload results
61
+ uses: actions/upload-artifact@v4
62
+ with:
63
+ name: bantay-results
64
+ path: bantay-results.json
65
+
66
+ - name: Check for failures
67
+ run: |
68
+ if grep -q '"status":"fail"' bantay-results.json; then
69
+ echo "Invariant violations found!"
70
+ cat bantay-results.json
71
+ exit 1
72
+ fi
73
+ echo "All invariants pass!"
74
+ `;
75
+ }
76
+
77
+ /**
78
+ * Generate GitLab CI configuration
79
+ */
80
+ function generateGitLabConfig(): string {
81
+ return `# Bantay invariant check stage
82
+ # Add this to your .gitlab-ci.yml
83
+
84
+ bantay:
85
+ stage: test
86
+ image: oven/bun:latest
87
+ script:
88
+ - bun install
89
+ - bunx @bantay/cli check --json > bantay-results.json
90
+ artifacts:
91
+ paths:
92
+ - bantay-results.json
93
+ reports:
94
+ dotenv: bantay-results.json
95
+ rules:
96
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
97
+ - if: '$CI_COMMIT_BRANCH == "main"'
98
+ - if: '$CI_COMMIT_BRANCH == "master"'
99
+ `;
100
+ }
101
+
102
+ /**
103
+ * Generate generic CI instructions
104
+ */
105
+ function generateGenericInstructions(): string {
106
+ return `# Bantay CI Integration
107
+
108
+ Run these commands in your CI pipeline:
109
+
110
+ ## Install Bun (if not available)
111
+ curl -fsSL https://bun.sh/install | bash
112
+
113
+ ## Install dependencies
114
+ bun install
115
+
116
+ ## Run invariant check
117
+ bunx @bantay/cli check
118
+
119
+ ## For JSON output (recommended for CI parsing)
120
+ bunx @bantay/cli check --json > bantay-results.json
121
+
122
+ ## Exit code
123
+ # - 0: All invariants pass
124
+ # - 1: One or more invariants failed
125
+ # - 2: Configuration error
126
+
127
+ ## Example for common CI systems:
128
+
129
+ ### CircleCI
130
+ # jobs:
131
+ # bantay:
132
+ # docker:
133
+ # - image: oven/bun:latest
134
+ # steps:
135
+ # - checkout
136
+ # - run: bun install
137
+ # - run: bunx @bantay/cli check
138
+
139
+ ### Jenkins (Jenkinsfile)
140
+ # stage('Bantay Check') {
141
+ # steps {
142
+ # sh 'curl -fsSL https://bun.sh/install | bash'
143
+ # sh 'bun install'
144
+ # sh 'bunx @bantay/cli check'
145
+ # }
146
+ # }
147
+
148
+ ### Azure Pipelines
149
+ # - script: |
150
+ # curl -fsSL https://bun.sh/install | bash
151
+ # export PATH="$HOME/.bun/bin:$PATH"
152
+ # bun install
153
+ # bunx @bantay/cli check
154
+ # displayName: 'Run Bantay check'
155
+ `;
156
+ }
157
+
158
+ /**
159
+ * Run CI generator
160
+ */
161
+ export async function runCi(
162
+ projectPath: string,
163
+ options: CiOptions
164
+ ): Promise<CiResult> {
165
+ const { provider, force } = options;
166
+
167
+ if (provider === "github-actions") {
168
+ const workflowDir = join(projectPath, ".github", "workflows");
169
+ const outputPath = join(workflowDir, "bantay.yml");
170
+
171
+ // Check if file already exists
172
+ if (await fileExists(outputPath)) {
173
+ if (!force) {
174
+ return {
175
+ provider: "github-actions",
176
+ outputPath,
177
+ content: "",
178
+ alreadyExists: true,
179
+ };
180
+ }
181
+ }
182
+
183
+ // Create directory if needed
184
+ await mkdir(workflowDir, { recursive: true });
185
+
186
+ // Generate and write workflow
187
+ const content = generateGitHubWorkflow();
188
+ await writeFile(outputPath, content, "utf-8");
189
+
190
+ return {
191
+ provider: "github-actions",
192
+ outputPath,
193
+ content,
194
+ };
195
+ }
196
+
197
+ if (provider === "gitlab") {
198
+ const outputPath = join(projectPath, ".gitlab-ci.bantay.yml");
199
+
200
+ // Check if file already exists
201
+ if (await fileExists(outputPath)) {
202
+ if (!force) {
203
+ return {
204
+ provider: "gitlab",
205
+ outputPath,
206
+ content: "",
207
+ alreadyExists: true,
208
+ };
209
+ }
210
+ }
211
+
212
+ // Generate and write config
213
+ const content = generateGitLabConfig();
214
+ await writeFile(outputPath, content, "utf-8");
215
+
216
+ return {
217
+ provider: "gitlab",
218
+ outputPath,
219
+ content,
220
+ };
221
+ }
222
+
223
+ // Generic - just return instructions, don't write file
224
+ return {
225
+ provider: "generic",
226
+ content: generateGenericInstructions(),
227
+ };
228
+ }
@@ -0,0 +1,309 @@
1
+ import { readFile, readdir, access } from "fs/promises";
2
+ import { join, relative } from "path";
3
+ import * as yaml from "js-yaml";
4
+
5
+ export interface StatusOptions {
6
+ json?: boolean;
7
+ }
8
+
9
+ export interface ScenarioStatus {
10
+ id: string;
11
+ name: string;
12
+ parentCuj: string;
13
+ status: "implemented" | "missing";
14
+ testFile?: string;
15
+ line?: number;
16
+ }
17
+
18
+ export interface StatusSummary {
19
+ total: number;
20
+ implemented: number;
21
+ missing: number;
22
+ }
23
+
24
+ export interface StatusResult {
25
+ scenarios: ScenarioStatus[];
26
+ summary: StatusSummary;
27
+ cujs?: Record<string, string>;
28
+ error?: string;
29
+ }
30
+
31
+ interface AideEntity {
32
+ parent?: string;
33
+ props?: {
34
+ name?: string;
35
+ feature?: string;
36
+ [key: string]: unknown;
37
+ };
38
+ }
39
+
40
+ interface AideFile {
41
+ entities: Record<string, AideEntity>;
42
+ }
43
+
44
+ async function fileExists(path: string): Promise<boolean> {
45
+ try {
46
+ await access(path);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ async function findTestFiles(testsDir: string): Promise<string[]> {
54
+ const files: string[] = [];
55
+
56
+ async function walk(dir: string): Promise<void> {
57
+ try {
58
+ const entries = await readdir(dir, { withFileTypes: true });
59
+ for (const entry of entries) {
60
+ const fullPath = join(dir, entry.name);
61
+ if (entry.isDirectory()) {
62
+ await walk(fullPath);
63
+ } else if (entry.name.endsWith(".test.ts") || entry.name.endsWith(".test.js")) {
64
+ files.push(fullPath);
65
+ }
66
+ }
67
+ } catch {
68
+ // Directory doesn't exist or isn't readable
69
+ }
70
+ }
71
+
72
+ await walk(testsDir);
73
+ return files;
74
+ }
75
+
76
+ async function searchTestFileForScenario(
77
+ testFilePath: string,
78
+ scenarioId: string
79
+ ): Promise<{ found: boolean; line?: number }> {
80
+ try {
81
+ const content = await readFile(testFilePath, "utf-8");
82
+ const lines = content.split("\n");
83
+
84
+ // Priority 1: Look for explicit scenario marker (highest priority)
85
+ // Formats: @scenario sc_xxx, // sc_xxx:, // sc_xxx, * @scenario sc_xxx
86
+ for (let i = 0; i < lines.length; i++) {
87
+ const line = lines[i];
88
+ if (
89
+ line.includes(`@scenario ${scenarioId}`) ||
90
+ line.includes(`@scenario: ${scenarioId}`) ||
91
+ line.includes(`// ${scenarioId}:`) ||
92
+ line.includes(`// ${scenarioId} `) ||
93
+ line.match(new RegExp(`\\*\\s*@scenario\\s+${scenarioId}\\b`))
94
+ ) {
95
+ return { found: true, line: i + 1 };
96
+ }
97
+ }
98
+
99
+ // Priority 2: Look for describe/test block containing exact scenario ID
100
+ for (let i = 0; i < lines.length; i++) {
101
+ const line = lines[i];
102
+ // Match scenario ID in describe block or test name
103
+ const isDescribeOrTest = /^\s*(describe|test|it)\s*\(/.test(line);
104
+ if (isDescribeOrTest && (line.includes(`"${scenarioId}"`) || line.includes(`'${scenarioId}'`))) {
105
+ return { found: true, line: i + 1 };
106
+ }
107
+ }
108
+
109
+ return { found: false };
110
+ } catch {
111
+ return { found: false };
112
+ }
113
+ }
114
+
115
+ function scenarioIdToTestFileName(scenarioId: string): string[] {
116
+ // Convert sc_init_prerequisites to possible test file names
117
+ // sc_init_prerequisites -> [prerequisites, init-prerequisites, init]
118
+ const withoutPrefix = scenarioId.replace(/^sc_/, "");
119
+ const parts = withoutPrefix.split("_");
120
+
121
+ const candidates: string[] = [];
122
+
123
+ // Full name with dashes: init-prerequisites
124
+ candidates.push(parts.join("-"));
125
+
126
+ // Last part only: prerequisites
127
+ if (parts.length > 1) {
128
+ candidates.push(parts[parts.length - 1]);
129
+ candidates.push(parts.slice(1).join("-"));
130
+ }
131
+
132
+ // First part only: init
133
+ candidates.push(parts[0]);
134
+
135
+ return candidates;
136
+ }
137
+
138
+ function testFileMatchesScenario(testFileName: string, scenarioId: string): boolean {
139
+ const baseName = testFileName.replace(/\.test\.(ts|js)$/, "");
140
+ const candidates = scenarioIdToTestFileName(scenarioId);
141
+
142
+ return candidates.some(candidate => baseName === candidate);
143
+ }
144
+
145
+ export async function runStatus(
146
+ projectPath: string,
147
+ options?: StatusOptions
148
+ ): Promise<StatusResult> {
149
+ const aidePath = join(projectPath, "bantay.aide");
150
+
151
+ // Check if aide file exists
152
+ if (!(await fileExists(aidePath))) {
153
+ return {
154
+ scenarios: [],
155
+ summary: { total: 0, implemented: 0, missing: 0 },
156
+ error: "bantay.aide not found",
157
+ };
158
+ }
159
+
160
+ // Parse aide file
161
+ let aideContent: AideFile;
162
+ try {
163
+ const rawContent = await readFile(aidePath, "utf-8");
164
+ aideContent = yaml.load(rawContent) as AideFile;
165
+ } catch (e) {
166
+ return {
167
+ scenarios: [],
168
+ summary: { total: 0, implemented: 0, missing: 0 },
169
+ error: `Failed to parse bantay.aide: ${e instanceof Error ? e.message : String(e)}`,
170
+ };
171
+ }
172
+
173
+ if (!aideContent?.entities) {
174
+ return {
175
+ scenarios: [],
176
+ summary: { total: 0, implemented: 0, missing: 0 },
177
+ error: "bantay.aide has no entities",
178
+ };
179
+ }
180
+
181
+ // Extract CUJ information
182
+ const cujs: Record<string, string> = {};
183
+ for (const [id, entity] of Object.entries(aideContent.entities)) {
184
+ if (id.startsWith("cuj_") && entity.props?.feature) {
185
+ cujs[id] = entity.props.feature;
186
+ }
187
+ }
188
+
189
+ // Extract all sc_* entities
190
+ const scenarios: ScenarioStatus[] = [];
191
+ for (const [id, entity] of Object.entries(aideContent.entities)) {
192
+ if (id.startsWith("sc_")) {
193
+ scenarios.push({
194
+ id,
195
+ name: entity.props?.name || id,
196
+ parentCuj: entity.parent || "unknown",
197
+ status: "missing",
198
+ });
199
+ }
200
+ }
201
+
202
+ // Find test files
203
+ const testsDir = join(projectPath, "tests");
204
+ const testFiles = await findTestFiles(testsDir);
205
+
206
+ // Match scenarios to test files
207
+ for (const scenario of scenarios) {
208
+ // Priority 1: Explicit scenario ID in test file
209
+ for (const testFile of testFiles) {
210
+ // Skip status-command.test.ts as it's a meta test that contains scenario IDs as test data
211
+ if (testFile.includes("status-command.test.ts")) {
212
+ continue;
213
+ }
214
+
215
+ const result = await searchTestFileForScenario(testFile, scenario.id);
216
+ if (result.found) {
217
+ scenario.status = "implemented";
218
+ scenario.testFile = relative(projectPath, testFile);
219
+ scenario.line = result.line;
220
+ break;
221
+ }
222
+ }
223
+
224
+ // Priority 2: Test file name matches scenario (if not already matched)
225
+ if (scenario.status === "missing") {
226
+ for (const testFile of testFiles) {
227
+ if (testFile.includes("status-command.test.ts")) {
228
+ continue;
229
+ }
230
+
231
+ const fileName = testFile.split("/").pop() || "";
232
+ if (testFileMatchesScenario(fileName, scenario.id)) {
233
+ // Found a file that matches by name, but verify it has relevant tests
234
+ try {
235
+ const content = await readFile(testFile, "utf-8");
236
+ // Ensure it's not an empty or stub test file
237
+ if (content.includes("describe(") || content.includes("test(")) {
238
+ scenario.status = "implemented";
239
+ scenario.testFile = relative(projectPath, testFile);
240
+ scenario.line = 1; // Start of file when matched by name
241
+ break;
242
+ }
243
+ } catch {
244
+ // Skip if can't read file
245
+ }
246
+ }
247
+ }
248
+ }
249
+ }
250
+
251
+ // Calculate summary
252
+ const implemented = scenarios.filter((s) => s.status === "implemented").length;
253
+ const missing = scenarios.filter((s) => s.status === "missing").length;
254
+
255
+ return {
256
+ scenarios,
257
+ summary: {
258
+ total: scenarios.length,
259
+ implemented,
260
+ missing,
261
+ },
262
+ cujs,
263
+ };
264
+ }
265
+
266
+ export function formatStatus(result: StatusResult): string {
267
+ if (result.error) {
268
+ return `Error: ${result.error}\n`;
269
+ }
270
+
271
+ const lines: string[] = [];
272
+ lines.push("# Scenario Implementation Status\n");
273
+
274
+ // Group scenarios by CUJ
275
+ const byParent: Record<string, ScenarioStatus[]> = {};
276
+ for (const scenario of result.scenarios) {
277
+ if (!byParent[scenario.parentCuj]) {
278
+ byParent[scenario.parentCuj] = [];
279
+ }
280
+ byParent[scenario.parentCuj].push(scenario);
281
+ }
282
+
283
+ // Output by CUJ
284
+ for (const [cuj, scenarios] of Object.entries(byParent)) {
285
+ const cujName = result.cujs?.[cuj] || cuj;
286
+ lines.push(`\n## ${cujName}\n`);
287
+
288
+ for (const scenario of scenarios) {
289
+ const icon = scenario.status === "implemented" ? "✓" : "○";
290
+ const location = scenario.testFile
291
+ ? `${scenario.testFile}${scenario.line ? `:${scenario.line}` : ""}`
292
+ : "";
293
+
294
+ lines.push(`${icon} ${scenario.id}: ${scenario.name}`);
295
+ if (location) {
296
+ lines.push(` → ${location}`);
297
+ }
298
+ }
299
+ }
300
+
301
+ // Summary
302
+ lines.push(`\n---`);
303
+ lines.push(
304
+ `Implemented: ${result.summary.implemented}/${result.summary.total} (${Math.round((result.summary.implemented / result.summary.total) * 100)}%)`
305
+ );
306
+ lines.push(`Missing: ${result.summary.missing}`);
307
+
308
+ return lines.join("\n");
309
+ }