@codyswann/lisa 2.346.0 → 2.348.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.
Files changed (68) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +36 -1
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/expo/copy-overwrite/scripts/bdd/baseline.mjs +224 -0
  5. package/expo/copy-overwrite/scripts/bdd/contract.mjs +121 -0
  6. package/expo/copy-overwrite/scripts/bdd/envelope.mjs +231 -0
  7. package/expo/copy-overwrite/scripts/bdd/parse.mjs +220 -0
  8. package/expo/copy-overwrite/scripts/bdd/render.mjs +170 -0
  9. package/expo/copy-overwrite/scripts/bdd/report.mjs +361 -0
  10. package/expo/copy-overwrite/scripts/bdd/validate.mjs +282 -0
  11. package/expo/copy-overwrite/scripts/bdd/waivers.mjs +210 -0
  12. package/expo/copy-overwrite/scripts/bdd-matrix.mjs +278 -0
  13. package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +657 -0
  14. package/expo/create-only/bdd/coverage-map.json +38 -0
  15. package/expo/create-only/bdd/features/.keep +5 -0
  16. package/expo/github-rulesets/bdd-coverage.json +33 -0
  17. package/expo/package-lisa/package.lisa.json +3 -0
  18. package/package.json +1 -1
  19. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-agy/plugin.json +1 -1
  22. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  25. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  31. package/plugins/lisa-expo-agy/plugin.json +1 -1
  32. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  42. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  47. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  52. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-rails-agy/plugin.json +1 -1
  57. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  62. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  67. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Gherkin feature parsing and repo-safe file resolution for the BDD gate.
3
+ *
4
+ * The parser is deliberately hand-rolled and dependency-free: the gate must
5
+ * run in a bare `node scripts/check-bdd-coverage.mjs` with no install step, in
6
+ * every repo, at a pinned Lisa revision.
7
+ *
8
+ * @module scripts/bdd/parse
9
+ */
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+
13
+ import {
14
+ ID_PATTERN,
15
+ LIFECYCLE_TAGS,
16
+ PROVENANCE_PATTERN,
17
+ parseTrackerTag,
18
+ } from "./contract.mjs";
19
+
20
+ const LIFECYCLE = new Set(LIFECYCLE_TAGS);
21
+
22
+ /** Normalize a path to posix separators so reports are OS-independent. */
23
+ export const posix = value => value.split(path.sep).join("/");
24
+
25
+ /**
26
+ * Resolve a coverage-map path inside the repo, refusing every escape.
27
+ *
28
+ * A coverage map is repo data that an author edits, so it is treated as
29
+ * untrusted input: absolute paths, `..` traversal, and symlinks that resolve
30
+ * outside the repo are rejected rather than followed. Refusing beats
31
+ * following — a mapping that reads a file outside the repo could "prove"
32
+ * coverage from anything on the runner.
33
+ * @param {string} root - Repo root.
34
+ * @param {string} relative - Repo-relative path from the coverage map.
35
+ * @returns {{path: string|null, error: string|null}} Absolute path, or the reason it was refused.
36
+ */
37
+ export function resolveInsideRepo(root, relative) {
38
+ if (typeof relative !== "string" || relative.length === 0) {
39
+ return { path: null, error: "path is missing" };
40
+ }
41
+ if (path.isAbsolute(relative) || /^[a-zA-Z]:/.test(relative)) {
42
+ return { path: null, error: "path must be repo-relative" };
43
+ }
44
+ const resolvedRoot = fs.realpathSync(root);
45
+ const candidate = path.resolve(resolvedRoot, relative);
46
+ if (!isInside(resolvedRoot, candidate)) {
47
+ return { path: null, error: "path escapes the repository" };
48
+ }
49
+ if (!fs.existsSync(candidate)) return { path: null, error: "file not found" };
50
+ const real = fs.realpathSync(candidate);
51
+ if (!isInside(resolvedRoot, real)) {
52
+ return { path: null, error: "symlink resolves outside the repository" };
53
+ }
54
+ return { path: real, error: null };
55
+ }
56
+
57
+ /**
58
+ * True when `child` is `parent` or lives under it.
59
+ * @param {string} parent - Containing directory.
60
+ * @param {string} child - Candidate path.
61
+ * @returns {boolean} Whether the candidate is contained.
62
+ */
63
+ function isInside(parent, child) {
64
+ const relative = path.relative(parent, child);
65
+ return (
66
+ relative === "" ||
67
+ (!relative.startsWith("..") && !path.isAbsolute(relative))
68
+ );
69
+ }
70
+
71
+ /**
72
+ * List files under a directory without following symlinked directories out
73
+ * of the tree, sorted for deterministic output.
74
+ * @param {string} directory - Directory to walk.
75
+ * @param {(file: string) => boolean} predicate - Filter on absolute paths.
76
+ * @returns {string[]} Matching absolute paths, sorted.
77
+ */
78
+ export function listFiles(directory, predicate) {
79
+ if (!fs.existsSync(directory)) return [];
80
+ const found = [];
81
+ const stack = [directory];
82
+ while (stack.length > 0) {
83
+ const current = stack.pop();
84
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
85
+ const full = path.join(current, entry.name);
86
+ if (entry.isDirectory()) stack.push(full);
87
+ else if (entry.isFile() && predicate(full)) found.push(full);
88
+ }
89
+ }
90
+ return found.sort();
91
+ }
92
+
93
+ /**
94
+ * Split one tag line into bare tag names.
95
+ * @param {string} trimmed - A trimmed source line beginning with `@`.
96
+ * @returns {string[]} Tag names without their leading `@`.
97
+ */
98
+ function tagsOf(trimmed) {
99
+ return trimmed
100
+ .split(/\s+/)
101
+ .filter(token => token.startsWith("@"))
102
+ .map(token => token.slice(1))
103
+ .filter(Boolean);
104
+ }
105
+
106
+ /**
107
+ * Bucket a scenario's pending tags into the contract's categories.
108
+ * @param {readonly string[]} tags - Raw tag names.
109
+ * @param {ReadonlySet<string>} platforms - The project's declared platform vocabulary.
110
+ * @returns {object} Categorized tags, including tracker-shaped references.
111
+ */
112
+ function categorize(tags, platforms) {
113
+ const trackers = [];
114
+ for (const tag of tags) {
115
+ const reference = parseTrackerTag(tag);
116
+ if (reference) trackers.push({ tag, ...reference });
117
+ }
118
+ return {
119
+ ids: tags.filter(tag => ID_PATTERN.test(tag)),
120
+ platforms: tags.filter(tag => platforms.has(tag)),
121
+ lifecycle: tags.filter(tag => LIFECYCLE.has(tag)),
122
+ provenance: tags.filter(tag => PROVENANCE_PATTERN.test(tag)),
123
+ trackers,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Parse one `.feature` source into scenarios.
129
+ *
130
+ * Tags accumulate until a `Feature:` or `Scenario:` consumes them, matching
131
+ * Gherkin's own tag scoping. `Scenario Outline` is treated as one scenario:
132
+ * the contract counts behaviors, not example rows.
133
+ * @param {string} source - File contents.
134
+ * @param {string} file - Repo-relative path, for error locations.
135
+ * @param {ReadonlySet<string>} platforms - The project's declared platform vocabulary.
136
+ * @returns {object[]} Parsed scenarios in source order.
137
+ */
138
+ export function parseFeatureSource(source, file, platforms) {
139
+ const scenarios = [];
140
+ const lines = source.split(/\r?\n/);
141
+ let feature = null;
142
+ let pending = [];
143
+ let current = null;
144
+ for (let index = 0; index < lines.length; index += 1) {
145
+ const trimmed = lines[index].trim();
146
+ if (trimmed.startsWith("#")) continue;
147
+ if (trimmed.startsWith("@")) {
148
+ pending.push(...tagsOf(trimmed));
149
+ continue;
150
+ }
151
+ const featureMatch = /^Feature:\s*(.+)$/.exec(trimmed);
152
+ if (featureMatch) {
153
+ feature = featureMatch[1].trim();
154
+ pending = [];
155
+ current = null;
156
+ continue;
157
+ }
158
+ const scenarioMatch = /^Scenario(?: Outline| Template)?:\s*(.+)$/.exec(
159
+ trimmed
160
+ );
161
+ if (scenarioMatch) {
162
+ const grouped = categorize(pending, platforms);
163
+ current = {
164
+ id: grouped.ids[0] ?? null,
165
+ name: scenarioMatch[1].trim(),
166
+ feature: feature ?? "Unknown feature",
167
+ file,
168
+ line: index + 1,
169
+ tags: [...pending],
170
+ ...grouped,
171
+ required: grouped.lifecycle.length === 0,
172
+ primarySteps: [],
173
+ };
174
+ scenarios.push(current);
175
+ pending = [];
176
+ continue;
177
+ }
178
+ const step = /^(Given|When|Then)\b/.exec(trimmed);
179
+ if (step && current) current.primarySteps.push(step[1]);
180
+ }
181
+ return scenarios;
182
+ }
183
+
184
+ /**
185
+ * Load every scenario declared under `bdd/features`.
186
+ * @param {string} root - Repo root.
187
+ * @param {ReadonlySet<string>} platforms - The project's declared platform vocabulary.
188
+ * @returns {object[]} Every parsed scenario, in stable file order.
189
+ */
190
+ export function loadScenarios(root, platforms) {
191
+ const featureRoot = path.join(root, "bdd", "features");
192
+ return listFiles(featureRoot, file => file.endsWith(".feature")).flatMap(
193
+ file =>
194
+ parseFeatureSource(
195
+ fs.readFileSync(file, "utf8"),
196
+ posix(path.relative(root, file)),
197
+ platforms
198
+ )
199
+ );
200
+ }
201
+
202
+ /**
203
+ * Extract the scenario IDs present in a raw set of feature sources.
204
+ *
205
+ * Used against a base revision to detect scenarios deleted rather than
206
+ * `@superseded` — the denominator-gaming move the contract forbids.
207
+ * @param {readonly string[]} sources - Feature file contents.
208
+ * @returns {Set<string>} Every `BDD-*` ID found.
209
+ */
210
+ export function scenarioIdsIn(sources) {
211
+ const ids = new Set();
212
+ for (const source of sources) {
213
+ for (const line of source.split(/\r?\n/)) {
214
+ const trimmed = line.trim();
215
+ if (!trimmed.startsWith("@")) continue;
216
+ for (const tag of tagsOf(trimmed)) if (ID_PATTERN.test(tag)) ids.add(tag);
217
+ }
218
+ }
219
+ return ids;
220
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Markdown rendering for the BDD burndown.
3
+ *
4
+ * The prose here is load-bearing: it is the only place a reader learns that
5
+ * the headline percentage is traceability, not execution and not a pass
6
+ * rate. Do not shorten it into a bare number.
7
+ *
8
+ * @module scripts/bdd/render
9
+ */
10
+
11
+ /**
12
+ * Format a coverage figure, refusing to print "100%" for an empty
13
+ * denominator — a platform with no obligations has no coverage to report,
14
+ * and a bare 100% there is the exact false headline this gate exists to
15
+ * prevent.
16
+ * @param {object} value - A covered/total/percentage summary.
17
+ * @returns {string} Cell text.
18
+ */
19
+ const fmt = value =>
20
+ value.total === 0
21
+ ? "n/a (no obligations)"
22
+ : `${value.covered}/${value.total} (${value.percentage.toFixed(1)}%)`;
23
+
24
+ /**
25
+ * Render the per-platform traceability table.
26
+ * @param {object} report - The coverage report.
27
+ * @returns {string} Markdown table.
28
+ */
29
+ function platformTable(report) {
30
+ const rows = Object.entries(report.traceability.byPlatform)
31
+ .map(([platform, value]) => `| ${platform} | ${fmt(value)} |`)
32
+ .join("\n");
33
+ return `| Platform | Obligations with mapped automation |\n|---|---:|\n${rows}\n| **Overall** | **${fmt(report.traceability.overall)}** |`;
34
+ }
35
+
36
+ /**
37
+ * Render the execution section, which is deliberately empty-but-explicit
38
+ * when no run evidence was supplied.
39
+ * @param {object} report - The coverage report.
40
+ * @returns {string} Markdown section body.
41
+ */
42
+ function executionSection(report) {
43
+ const execution = report.execution;
44
+ if (!execution.supplied) {
45
+ return `**No execution evidence was supplied to this run.** ${execution.mappedTests} mapped tests exist; how many ran, and what they returned, is unknown here. Pass \`--results <file>\` with a runner result document to populate this section. Traceability without execution proves automation was written, never that it works.`;
46
+ }
47
+ const sources = execution.sources
48
+ .map(
49
+ source =>
50
+ `- \`${source.runner}\` run \`${source.runId ?? "unidentified"}\` (${source.resultCount} results, completed ${source.completedAt ?? "unknown"})`
51
+ )
52
+ .join("\n");
53
+ return `| Mapped tests | Executed | Passed | Failed | Skipped | Not run |\n|---:|---:|---:|---:|---:|---:|\n| ${execution.mappedTests} | ${execution.executed} | ${execution.passed} | ${execution.failed} | ${execution.skipped} | ${execution.notRun} |\n\nSources:\n\n${sources}`;
54
+ }
55
+
56
+ /**
57
+ * Render the waiver ledger.
58
+ * @param {object} report - The coverage report.
59
+ * @returns {string} Markdown table.
60
+ */
61
+ function waiverTable(report) {
62
+ const rows =
63
+ report.waived.entries
64
+ .map(
65
+ entry =>
66
+ `| ${entry.scenario} | ${entry.platforms.join(", ")} | ${entry.runner ?? "—"} | ${entry.owner ?? "**unowned**"} | ${entry.reason ?? "—"} | ${entry.ticket ?? "**none**"} | ${entry.expiresAt ?? "**never**"} |`
67
+ )
68
+ .join("\n") || "| — | — | — | — | None | — | — |";
69
+ return `| Scenario | Platforms | Runner | Owner | Reason | Ticket | Expires |\n|---|---|---|---|---|---|---|\n${rows}`;
70
+ }
71
+
72
+ /**
73
+ * Render the floor ratchet table.
74
+ * @param {object} report - The coverage report.
75
+ * @returns {string} Markdown table.
76
+ */
77
+ function floorTable(report) {
78
+ const rows = Object.entries(report.floor.byPlatform)
79
+ .map(
80
+ ([platform, value]) =>
81
+ `| ${platform} | ${value.floor === null ? "**unset**" : `${value.floor}%`} | ${value.actual}% | ${value.ok ? "ok" : "**below floor**"} |`
82
+ )
83
+ .join("\n");
84
+ return `| Platform | Committed floor | Current | Status |\n|---|---:|---:|---|\n${rows}`;
85
+ }
86
+
87
+ /**
88
+ * Group the remaining gaps by feature.
89
+ * @param {object} report - The coverage report.
90
+ * @returns {string} Markdown sections.
91
+ */
92
+ function gapSections(report) {
93
+ const grouped = new Map();
94
+ for (const gap of report.gaps) {
95
+ const entries = grouped.get(gap.feature) ?? new Map();
96
+ const entry = entries.get(gap.scenario) ?? {
97
+ name: gap.name,
98
+ platforms: [],
99
+ };
100
+ entry.platforms.push(gap.platform);
101
+ entries.set(gap.scenario, entry);
102
+ grouped.set(gap.feature, entries);
103
+ }
104
+ const sections = [...grouped.entries()]
105
+ .sort(([a], [b]) => a.localeCompare(b))
106
+ .map(([feature, entries]) => {
107
+ const rows = [...entries.entries()]
108
+ .map(
109
+ ([id, value]) =>
110
+ `| ${id} | ${value.name} | ${value.platforms.sort().join(", ")} |`
111
+ )
112
+ .join("\n");
113
+ return `### ${feature}\n\n| Scenario | Behavior with no mapped proof | Platforms |\n|---|---|---|\n${rows}`;
114
+ });
115
+ return (
116
+ sections.join("\n\n") ||
117
+ "None. Every required, non-waived obligation has aligned automation mapped to it."
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Render the whole burndown document.
123
+ * @param {object} report - The coverage report.
124
+ * @returns {string} Markdown document.
125
+ */
126
+ export function renderBurndown(report) {
127
+ return `# BDD behavior contract — coverage burndown
128
+
129
+ Generated from \`bdd/features/*.feature\` and \`bdd/coverage-map.json\` as of ${report.asOf ?? "an unrecorded date"}.
130
+ Regenerated by \`node scripts/check-bdd-coverage.mjs --write\`; never hand-edited.
131
+
132
+ ## What each number means
133
+
134
+ This document reports five different facts and never merges them:
135
+
136
+ 1. **Declared** — behaviors written as Gherkin scenarios. ${report.scenarios.declared} total, ${report.scenarios.required} in the denominator, ${report.scenarios.excluded} excluded (${report.scenarios.blocked} blocked, ${report.scenarios.referenceOnly} reference-only, ${report.scenarios.superseded} superseded).
137
+ 2. **Traceability coverage** — required obligations with aligned automation mapped and their evidence string still present. **This is not execution coverage and not a pass rate.** A mapped test that fails on every run still counts here.
138
+ 3. **Execution** — how many of those mapped tests actually ran in a supplied run.
139
+ 4. **Results** — what those runs returned: pass, fail, skip.
140
+ 5. **Waivers** — obligations deliberately outside the denominator. A waiver is a dated IOU with an owner and a retiring ticket; it is never coverage.
141
+
142
+ ## Traceability coverage
143
+
144
+ ${platformTable(report)}
145
+
146
+ ## Execution
147
+
148
+ ${executionSection(report)}
149
+
150
+ ## Coverage floor (ratchet)
151
+
152
+ The committed floor may rise and may never fall. Lowering it requires a \`coverageFloorBaseline\` record naming the exact change plus the maintainer-applied \`bdd-floor-baseline\` label — two artifacts one author cannot produce alone.
153
+
154
+ ${floorTable(report)}
155
+
156
+ ## Waived obligations
157
+
158
+ ${report.waived.note}
159
+
160
+ ${waiverTable(report)}
161
+
162
+ ## Traceability to work items
163
+
164
+ ${report.trackers.scenariosWithTag} of ${report.scenarios.declared} scenarios carry a tracker tag; ${report.trackers.scenariosWithoutTag} do not. Tags are validated for syntax and against this repo's declared trackers; the gate never contacts a tracker, so an unreachable issue can never block a merge.
165
+
166
+ ## Required obligations with no mapped proof
167
+
168
+ ${gapSections(report)}
169
+ `;
170
+ }