@codyswann/lisa 4.73.9 → 4.74.1

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 (119) hide show
  1. package/README.md +52 -3
  2. package/all/create-only/.github/workflows/continuous-gates.yml +2 -2
  3. package/all/create-only/.github/workflows/workflow-load-failure-sweep.yml +1 -1
  4. package/cdk/create-only/.github/workflows/ci.yml +2 -2
  5. package/cdk/create-only/.github/workflows/deploy.yml +3 -3
  6. package/cdk/package-lisa/package.lisa.json +1 -1
  7. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  8. package/dist/core/lisa-owned-hash-ledger.js +60 -0
  9. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  10. package/dist/core/lisa-owned-templates.d.ts +9 -2
  11. package/dist/core/lisa-owned-templates.d.ts.map +1 -1
  12. package/dist/core/lisa-owned-templates.js +13 -3
  13. package/dist/core/lisa-owned-templates.js.map +1 -1
  14. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  15. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  16. package/dist/core/upstream-evidence-manifest.js +87 -33
  17. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  18. package/expo/copy-overwrite/jest.setup.pre.js +15 -1
  19. package/expo/copy-overwrite/scripts/bdd/ci-summary.mjs +37 -0
  20. package/expo/copy-overwrite/scripts/bdd/projection-model.mjs +157 -0
  21. package/expo/copy-overwrite/scripts/bdd/projection-ownership.mjs +300 -0
  22. package/expo/copy-overwrite/scripts/bdd/projection-paths.mjs +152 -0
  23. package/expo/copy-overwrite/scripts/bdd/projection-render.mjs +303 -0
  24. package/expo/copy-overwrite/scripts/bdd/projection-write.mjs +84 -0
  25. package/expo/copy-overwrite/scripts/bdd/projection.mjs +30 -0
  26. package/expo/copy-overwrite/scripts/bdd-matrix.mjs +54 -18
  27. package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +36 -27
  28. package/expo/create-only/.github/workflows/ci.yml +3 -3
  29. package/expo/create-only/.github/workflows/deploy.yml +4 -4
  30. package/expo/create-only/.github/workflows/nightly-e2e-bypass-reaper.yml +1 -1
  31. package/expo/create-only/.github/workflows/playwright-e2e.yml +1 -1
  32. package/expo/create-only/eas.json +1 -1
  33. package/harper-fabric/copy-overwrite/.github/workflows/ci.yml +1 -1
  34. package/harper-fabric/create-only/.github/workflows/deploy.yml +3 -3
  35. package/harper-fabric/create-only/.github/workflows/zap-baseline.yml +1 -1
  36. package/harper-fabric/package-lisa/package.lisa.json +2 -2
  37. package/nestjs/create-only/.github/workflows/ci.yml +2 -2
  38. package/nestjs/create-only/.github/workflows/deploy.yml +5 -5
  39. package/package.json +6 -6
  40. package/phaser/copy-overwrite/.github/workflows/ci.yml +1 -1
  41. package/phaser/package-lisa/package.lisa.json +2 -2
  42. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa/.codex-plugin/skills/lisa-parity-sentry-sdk-setup/SKILL.md +104 -0
  45. package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/scripts/workstation.mjs +3 -1
  46. package/plugins/lisa/rules/reference/bdd-e2e-coverage.md +9 -7
  47. package/plugins/lisa/skills/lisa-parity-sentry-sdk-setup/SKILL.md +104 -0
  48. package/plugins/lisa/skills/lisa-setup-workstation/scripts/workstation.mjs +3 -1
  49. package/plugins/lisa-agy/plugin.json +1 -1
  50. package/plugins/lisa-agy/skills/lisa-parity-sentry-sdk-setup/SKILL.md +104 -0
  51. package/plugins/lisa-agy/skills/lisa-setup-workstation/scripts/workstation.mjs +3 -1
  52. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  55. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-copilot/rules/reference/bdd-e2e-coverage.md +9 -7
  59. package/plugins/lisa-copilot/skills/lisa-parity-sentry-sdk-setup/SKILL.md +104 -0
  60. package/plugins/lisa-copilot/skills/lisa-setup-workstation/scripts/workstation.mjs +3 -1
  61. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-cursor/rules/bdd-e2e-coverage-reference.mdc +9 -7
  63. package/plugins/lisa-cursor/skills/lisa-parity-sentry-sdk-setup/SKILL.md +104 -0
  64. package/plugins/lisa-cursor/skills/lisa-setup-workstation/scripts/workstation.mjs +3 -1
  65. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-expo-agy/plugin.json +1 -1
  68. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  73. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  78. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  83. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  87. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  88. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  92. package/plugins/lisa-rails-agy/plugin.json +1 -1
  93. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  97. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  98. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  102. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  103. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  105. package/plugins/src/base/rules/reference/bdd-e2e-coverage.md +9 -7
  106. package/plugins/src/base/skills/lisa-parity-sentry-sdk-setup/SKILL.md +104 -0
  107. package/plugins/src/base/skills/lisa-setup-workstation/scripts/workstation.mjs +3 -1
  108. package/scripts/check-shell-guard-refusal-coverage.mjs +65 -7
  109. package/scripts/generate-lisa-owned-hash-ledger.mjs +3 -0
  110. package/scripts/lib/shell-guard-shard-runner.mjs +381 -0
  111. package/scripts/lib/shell-guard-shards.mjs +383 -0
  112. package/scripts/lib/shell-guard-terminal-reporter.mjs +89 -0
  113. package/scripts/two-channel-couplings.json +2 -2
  114. package/scripts/update-node-version.ts +52 -6
  115. package/typescript/copy-overwrite/.nvmrc +1 -1
  116. package/typescript/create-only/.github/workflows/ci.yml +1 -1
  117. package/typescript/create-only/.github/workflows/review-evidence.yml +1 -1
  118. package/typescript/create-only/.github/workflows/third-party-review-evidence.yml +1 -1
  119. package/typescript/package-lisa/package.lisa.json +1 -1
@@ -66,8 +66,22 @@ global.nativeFabricUIManager = {};
66
66
  // Mock TurboModuleRegistry using external mock configuration
67
67
  const mockModules = require("./jest.config.react-native-mock");
68
68
 
69
+ // Baseline native modules needed to load real View/Text under Jest. These
70
+ // fallbacks also support older project-owned registries without editing them.
71
+ // SourceCode matches @react-native/jest-preset's null scriptURL unit seam;
72
+ // PaperUIManager reads getConstants during initialization, with no native views.
73
+ const defaultMockModules = {
74
+ SourceCode: { getConstants: () => ({ scriptURL: null }) },
75
+ UIManager: { getConstants: () => ({}) },
76
+ };
77
+
69
78
  global.__turboModuleProxy = function (moduleName) {
70
- return mockModules[moduleName] || null;
79
+ return (
80
+ mockModules[moduleName] ??
81
+ (Object.hasOwn(defaultMockModules, moduleName)
82
+ ? defaultMockModules[moduleName]
83
+ : null)
84
+ );
71
85
  };
72
86
 
73
87
  // Ensure global timers are available (required by @testing-library/react-native)
@@ -0,0 +1,37 @@
1
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
2
+ // Do not edit directly — durable changes belong upstream in Lisa.
3
+
4
+ /** Publish the calculated aggregate from the same run that sets the CLI exit. */
5
+ import * as fs from "node:fs";
6
+
7
+ import { cell } from "./markdown-cell.mjs";
8
+ import { renderBurndown } from "./render.mjs";
9
+
10
+ /**
11
+ * Append a native GitHub job summary without rerunning or weakening the gate.
12
+ * Invalid input has no trustworthy totals, even if diagnostic data exists.
13
+ * @param {object} gateRun - Actual report, final status and original defects.
14
+ * @param {string|undefined} destination - Native GITHUB_STEP_SUMMARY path.
15
+ * @returns {void}
16
+ */
17
+ export function appendCiSummary(gateRun, destination) {
18
+ if (!destination) return;
19
+ const aggregate =
20
+ gateRun.report && gateRun.status !== "invalid"
21
+ ? renderBurndown(gateRun.report).trim()
22
+ : "**Global totals unavailable:** the behavior contract could not be evaluated.";
23
+ const findings = gateRun.defects.length
24
+ ? [
25
+ "| Finding | Detail |",
26
+ "|---|---|",
27
+ ...gateRun.defects.map(
28
+ item => `| ${cell(item.code)} | ${cell(item.message)} |`
29
+ ),
30
+ ].join("\n")
31
+ : "No findings.";
32
+ fs.appendFileSync(
33
+ destination,
34
+ `\n### BDD aggregate result: ${cell(gateRun.status)}\n\n${aggregate}\n\n${findings}\n`,
35
+ "utf8"
36
+ );
37
+ }
@@ -0,0 +1,157 @@
1
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
2
+ // Do not edit directly — durable changes belong upstream in Lisa.
3
+
4
+ /** Feature-local facts, sharing the gate's genuine evidence and result joins. */
5
+ import { byCodeUnit, trackerUrl } from "./contract.mjs";
6
+ import { indexResults } from "./report.mjs";
7
+ import { evidenceResolves } from "./validate.mjs";
8
+ import {
9
+ FEATURE_PROJECTION_SCHEMA,
10
+ PROJECTION_GENERATOR,
11
+ projectionSourceFile,
12
+ validateProjectionCollisions,
13
+ } from "./projection-paths.mjs";
14
+
15
+ /** Malformed optional collections remain the original validator's concern. */
16
+ const list = value => (Array.isArray(value) ? value : []);
17
+ /** Stable local vocabulary without modifying the parsed declaration. */
18
+ const words = value => [...list(value)].sort(byCodeUnit);
19
+ /** Preserve complete associated records instead of substituting a boolean. */
20
+ const recordsFor = (records, id) =>
21
+ list(records).filter(
22
+ record =>
23
+ record !== null && typeof record === "object" && record.scenario === id
24
+ );
25
+
26
+ /** Avoid crashing reportable red input solely while constructing its paperwork. */
27
+ function referenceUrl(reference, trackers) {
28
+ if (
29
+ reference.scheme !== "gh" &&
30
+ typeof trackers?.keyUrlTemplate !== "string"
31
+ ) {
32
+ return null;
33
+ }
34
+ return trackerUrl(reference, trackers);
35
+ }
36
+
37
+ /** Preserve supplied/absent/not-run and the original worst-result precedence. */
38
+ function mappingExecution(mapping, results, supplied) {
39
+ if (!supplied)
40
+ return { supplied: false, status: "not supplied", runId: null };
41
+ const result = results.get(
42
+ `${mapping.runner}|${mapping.file}|${mapping.evidence}`
43
+ );
44
+ return {
45
+ supplied: true,
46
+ status: result?.status ?? "not run",
47
+ runId: result?.runId ?? null,
48
+ };
49
+ }
50
+
51
+ /** Keep every declared mapping and its actual evidence verdict visible. */
52
+ function mappingRows(root, scenario, context) {
53
+ return recordsFor(context.contract.mappings, scenario.id)
54
+ .map(mapping => ({
55
+ ...mapping,
56
+ scenario: mapping.scenario ?? null,
57
+ runner: mapping.runner ?? null,
58
+ file: mapping.file ?? null,
59
+ evidence: mapping.evidence ?? null,
60
+ platforms: words(mapping.platforms),
61
+ evidenceResolution: evidenceResolves(root, mapping, context.cache),
62
+ execution: mappingExecution(mapping, context.results, context.supplied),
63
+ }))
64
+ .sort((left, right) =>
65
+ byCodeUnit(
66
+ JSON.stringify([left.runner, left.file, left.evidence, left.platforms]),
67
+ JSON.stringify([
68
+ right.runner,
69
+ right.file,
70
+ right.evidence,
71
+ right.platforms,
72
+ ])
73
+ )
74
+ );
75
+ }
76
+
77
+ /** A waiver is outside the obligation denominator and is never coverage. */
78
+ function obligation(platform, mappings, waivers, runnerPlatforms) {
79
+ const waived = waivers.some(record =>
80
+ list(record.platforms).includes(platform)
81
+ );
82
+ const covered =
83
+ !waived &&
84
+ mappings.some(
85
+ mapping =>
86
+ mapping.platforms.includes(platform) && mapping.evidenceResolution.ok
87
+ );
88
+ const runners = Object.keys(runnerPlatforms ?? {})
89
+ .filter(runner => list(runnerPlatforms[runner]).includes(platform))
90
+ .sort(byCodeUnit);
91
+ return { platform, runners, covered, waived, gap: !covered && !waived };
92
+ }
93
+
94
+ /** One complete scenario row; no global tally, inventory or run roster. */
95
+ function scenarioRow(root, scenario, context) {
96
+ const mappings = mappingRows(root, scenario, context);
97
+ const waivers = recordsFor(context.contract.platformWaivers, scenario.id);
98
+ return {
99
+ id: scenario.id ?? null,
100
+ name: scenario.name,
101
+ feature: scenario.feature,
102
+ line: scenario.line,
103
+ featureLine: scenario.featureLine,
104
+ tags: words(scenario.tags),
105
+ ownTags: words(scenario.ownTags),
106
+ inheritedTags: words(scenario.inheritedTags),
107
+ featureIdTags: words(scenario.featureIdTags),
108
+ platforms: words(scenario.platforms),
109
+ lifecycle: words(scenario.lifecycle),
110
+ required: scenario.required,
111
+ provenance: words(scenario.provenance),
112
+ trackers: list(scenario.trackers)
113
+ .map(reference => ({
114
+ ...reference,
115
+ url: referenceUrl(reference, context.contract.trackers),
116
+ }))
117
+ .sort((left, right) => byCodeUnit(left.tag, right.tag)),
118
+ primarySteps: [...list(scenario.primarySteps)],
119
+ mappings,
120
+ obligations: scenario.required
121
+ ? words(scenario.platforms).map(platform =>
122
+ obligation(
123
+ platform,
124
+ mappings,
125
+ waivers,
126
+ context.contract.runnerPlatforms
127
+ )
128
+ )
129
+ : [],
130
+ waivers,
131
+ retirements: recordsFor(context.contract.retirements, scenario.id),
132
+ };
133
+ }
134
+
135
+ /** Group by source path, never by a potentially duplicate Feature title. */
136
+ export function buildFeatureModels(
137
+ root,
138
+ { contract, scenarios, runs = [], cache = new Map() }
139
+ ) {
140
+ const sourcePaths = [...new Set(scenarios.map(scenario => scenario.file))];
141
+ validateProjectionCollisions(sourcePaths);
142
+ const context = {
143
+ contract,
144
+ cache,
145
+ results: indexResults(runs),
146
+ supplied: runs.length > 0,
147
+ };
148
+ return sourcePaths.sort(byCodeUnit).map(sourceFile => ({
149
+ schemaVersion: FEATURE_PROJECTION_SCHEMA,
150
+ generator: PROJECTION_GENERATOR,
151
+ sourceFile: projectionSourceFile(sourceFile),
152
+ scenarios: scenarios
153
+ .filter(scenario => scenario.file === sourceFile)
154
+ .sort((left, right) => left.line - right.line)
155
+ .map(scenario => scenarioRow(root, scenario, context)),
156
+ }));
157
+ }
@@ -0,0 +1,300 @@
1
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
2
+ // Do not edit directly — durable changes belong upstream in Lisa.
3
+
4
+ /** Refuse ambiguous ownership before writing or removing any generated leaf. */
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import {
8
+ FEATURE_PROJECTION_SCHEMA,
9
+ PROJECTION_GENERATOR,
10
+ PROJECTION_DIRECTORIES,
11
+ featureArtifactPaths,
12
+ inspectProjectionPath,
13
+ listProjectionPaths,
14
+ projectionStat,
15
+ refuseProjection,
16
+ validateProjectionCollisions,
17
+ } from "./projection-paths.mjs";
18
+ import {
19
+ STATIC_ARTIFACTS,
20
+ MATRIX_COMMAND,
21
+ COVERAGE_COMMAND,
22
+ PROJECTION_INDEX_SCHEMA,
23
+ REPORT_COMMAND,
24
+ AGGREGATE_MATRIX_COMMAND,
25
+ } from "./projection-render.mjs";
26
+
27
+ const MATRIX_TITLE = "# BDD scenario traceability matrix\n";
28
+
29
+ /** Unknown JSON is preserved; desired occupation is rejected separately. */
30
+ function parseJson(body) {
31
+ try {
32
+ return JSON.parse(body);
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /** Structural checks identify the versioned complete-local format. */
39
+ function validScenario(row) {
40
+ const arrays = [
41
+ "tags",
42
+ "ownTags",
43
+ "inheritedTags",
44
+ "featureIdTags",
45
+ "platforms",
46
+ "lifecycle",
47
+ "provenance",
48
+ "trackers",
49
+ "primarySteps",
50
+ "mappings",
51
+ "obligations",
52
+ "waivers",
53
+ "retirements",
54
+ ];
55
+ if (
56
+ !row ||
57
+ typeof row !== "object" ||
58
+ !arrays.every(key => Array.isArray(row[key]))
59
+ )
60
+ return false;
61
+ if (
62
+ typeof row.name !== "string" ||
63
+ typeof row.feature !== "string" ||
64
+ typeof row.required !== "boolean"
65
+ )
66
+ return false;
67
+ if (!Number.isInteger(row.line) || !Number.isInteger(row.featureLine))
68
+ return false;
69
+ return (
70
+ row.mappings.every(
71
+ mapping =>
72
+ mapping?.evidenceResolution &&
73
+ typeof mapping.evidenceResolution.ok === "boolean" &&
74
+ typeof mapping.execution?.supplied === "boolean" &&
75
+ Array.isArray(mapping.platforms)
76
+ ) &&
77
+ row.obligations.every(
78
+ value =>
79
+ typeof value?.platform === "string" &&
80
+ [value.covered, value.waived, value.gap].every(
81
+ flag => typeof flag === "boolean"
82
+ )
83
+ )
84
+ );
85
+ }
86
+
87
+ /** A claimed schema is never treated as anonymous handwritten content. */
88
+ function jsonOwnership(relative, body) {
89
+ const value = parseJson(body);
90
+ const claimed =
91
+ value?.generator === PROJECTION_GENERATOR ||
92
+ String(value?.schemaVersion ?? "").startsWith(
93
+ "lisa-bdd-feature-projection-"
94
+ );
95
+ if (!claimed) return false;
96
+ if (
97
+ value.schemaVersion !== FEATURE_PROJECTION_SCHEMA ||
98
+ value.generator !== PROJECTION_GENERATOR ||
99
+ !Array.isArray(value.scenarios) ||
100
+ !value.scenarios.every(validScenario)
101
+ ) {
102
+ refuseProjection(`unknown or malformed owned JSON schema: ${relative}`);
103
+ }
104
+ if (featureArtifactPaths(value.sourceFile)[0] !== relative) {
105
+ refuseProjection(`JSON ownership/source path mismatch: ${relative}`);
106
+ }
107
+ return true;
108
+ }
109
+
110
+ /** Markdown markers encode their source and distinguish the two document kinds. */
111
+ function markdownOwnership(relative, body) {
112
+ if (!body.startsWith("<!-- lisa-bdd-feature-projection-")) return false;
113
+ const marker =
114
+ /^<!-- (lisa-bdd-feature-projection-[^\s]+) (\S+) source=([^\s]+) -->\n/.exec(
115
+ body
116
+ );
117
+ if (
118
+ !marker ||
119
+ marker[1] !== FEATURE_PROJECTION_SCHEMA ||
120
+ !["matrix", "coverage"].includes(marker[2])
121
+ ) {
122
+ refuseProjection(`unknown or malformed owned Markdown schema: ${relative}`);
123
+ }
124
+ let source;
125
+ try {
126
+ source = decodeURIComponent(marker[3]);
127
+ } catch {
128
+ refuseProjection(`invalid Markdown source encoding: ${relative}`);
129
+ }
130
+ const index = marker[2] === "matrix" ? 1 : 2;
131
+ const title =
132
+ index === 1
133
+ ? MATRIX_TITLE
134
+ : "# BDD behavior contract — feature coverage burndown\n";
135
+ if (
136
+ featureArtifactPaths(source)[index] !== relative ||
137
+ !body.slice(marker[0].length).startsWith(title)
138
+ ) {
139
+ refuseProjection(`Markdown ownership/source path mismatch: ${relative}`);
140
+ }
141
+ return true;
142
+ }
143
+
144
+ /** Only explicit versioned leaves qualify for obsolete cleanup. */
145
+ export function ownedFeatureLeaf(relative, body) {
146
+ return relative.endsWith(".json")
147
+ ? jsonOwnership(relative, body)
148
+ : markdownOwnership(relative, body);
149
+ }
150
+
151
+ /** Recognize the previous aggregate format, never arbitrary JSON at its path. */
152
+ function legacyJson(body) {
153
+ const value = parseJson(body);
154
+ const objects = [
155
+ "scenarios",
156
+ "traceability",
157
+ "execution",
158
+ "testInventory",
159
+ "waived",
160
+ "floor",
161
+ "trackers",
162
+ ];
163
+ return (
164
+ value?.schemaVersion === 3 &&
165
+ objects.every(
166
+ key =>
167
+ value[key] &&
168
+ typeof value[key] === "object" &&
169
+ !Array.isArray(value[key])
170
+ ) &&
171
+ Array.isArray(value.gaps)
172
+ );
173
+ }
174
+
175
+ /** Migration is limited to canonical old headings, commands and content signatures. */
176
+ function legacyEntrypoint(relative, body) {
177
+ if (relative === "bdd/coverage-report.json") return legacyJson(body);
178
+ if (relative === "docs/bdd-scenario-matrix.md") {
179
+ return (
180
+ body.startsWith(MATRIX_TITLE) &&
181
+ body.includes(`Generated by \`${MATRIX_COMMAND}\`; never hand-edited.`) &&
182
+ body.includes("scenarios declared")
183
+ );
184
+ }
185
+ return (
186
+ body.startsWith("# BDD behavior contract — coverage burndown\n") &&
187
+ body.includes(
188
+ "Generated from `bdd/features/*.feature` and `bdd/coverage-map.json`"
189
+ ) &&
190
+ body.includes(
191
+ `Regenerated by \`${COVERAGE_COMMAND}\`; never hand-edited.`
192
+ ) &&
193
+ body.includes("## What each number means\n")
194
+ );
195
+ }
196
+
197
+ /** Known index versions remain refreshable when generated guidance changes. */
198
+ function ownedEntrypoint(relative, body) {
199
+ if (relative === "bdd/coverage-report.json") {
200
+ const value = parseJson(body);
201
+ return (
202
+ value?.schemaVersion === PROJECTION_INDEX_SCHEMA &&
203
+ value.generator === PROJECTION_GENERATOR &&
204
+ value.featureReports === "bdd/reports/v1/features/" &&
205
+ value.scenarioMatrices === "docs/bdd-scenario-matrix/" &&
206
+ value.coverageDocuments === "docs/e2e-bdd-coverage/" &&
207
+ value.sourceIdentity ===
208
+ "Full repository-relative bdd/features/*.feature path" &&
209
+ value.commands?.matrix === MATRIX_COMMAND &&
210
+ value.commands?.coverage === COVERAGE_COMMAND &&
211
+ value.aggregateCommands?.matrix === AGGREGATE_MATRIX_COMMAND &&
212
+ value.aggregateCommands?.coverage === REPORT_COMMAND &&
213
+ typeof value.aggregate === "string"
214
+ );
215
+ }
216
+ const matrix = relative === "docs/bdd-scenario-matrix.md";
217
+ const title = matrix
218
+ ? MATRIX_TITLE
219
+ : "# BDD behavior contract — coverage burndown\n";
220
+ const command = matrix
221
+ ? `Generated by \`${MATRIX_COMMAND}\`; never hand-edited.`
222
+ : `Regenerated by \`${COVERAGE_COMMAND}\`; never hand-edited.`;
223
+ return (
224
+ body.startsWith(title) &&
225
+ body.includes(command) &&
226
+ body.includes("../bdd/reports/v1/features/") &&
227
+ body.includes(
228
+ matrix
229
+ ? "[bdd-scenario-matrix/](bdd-scenario-matrix/)"
230
+ : "[e2e-bdd-coverage/](e2e-bdd-coverage/)"
231
+ ) &&
232
+ body.includes(
233
+ "This entrypoint intentionally stores no feature list or global counts."
234
+ )
235
+ );
236
+ }
237
+
238
+ /** Existing case/NFC aliases in ancestors are unsafe even on a permissive disk. */
239
+ function inspectAliases(root, relative) {
240
+ let directory = root;
241
+ for (const component of relative.split("/")) {
242
+ if (!projectionStat(directory)) return;
243
+ const key = component.normalize("NFC").toLowerCase();
244
+ const alias = fs
245
+ .readdirSync(directory)
246
+ .find(
247
+ name =>
248
+ name !== component && name.normalize("NFC").toLowerCase() === key
249
+ );
250
+ if (alias !== undefined)
251
+ refuseProjection(`case/NFC path collision: ${relative} aliases ${alias}`);
252
+ directory = path.join(directory, component);
253
+ }
254
+ }
255
+
256
+ /** Validate existing bytes without mutating desired targets or unknown neighbors. */
257
+ export function validateProjectionTarget(root, relative, desired) {
258
+ if (!inspectProjectionPath(root, relative)) return null;
259
+ const body = fs.readFileSync(path.join(root, relative), "utf8");
260
+ if (STATIC_ARTIFACTS.has(relative)) {
261
+ if (
262
+ body !== desired &&
263
+ !legacyEntrypoint(relative, body) &&
264
+ !ownedEntrypoint(relative, body)
265
+ ) {
266
+ refuseProjection(
267
+ `unowned static entrypoint or unsupported schema: ${relative}`
268
+ );
269
+ }
270
+ } else if (!ownedFeatureLeaf(relative, body)) {
271
+ refuseProjection(`unowned content occupies generated path: ${relative}`);
272
+ }
273
+ return body;
274
+ }
275
+
276
+ /** Full safety/ownership inventory precedes all directory and file mutation. */
277
+ export function prepareProjectionWrite(root, artifacts) {
278
+ const candidates = PROJECTION_DIRECTORIES.flatMap(directory =>
279
+ listProjectionPaths(root, directory)
280
+ );
281
+ const allPaths = [...artifacts.keys(), ...candidates];
282
+ validateProjectionCollisions(allPaths);
283
+ for (const relative of artifacts.keys()) {
284
+ inspectProjectionPath(root, relative);
285
+ inspectAliases(root, relative);
286
+ }
287
+ const obsolete = [];
288
+ for (const relative of candidates) {
289
+ if (!projectionStat(path.join(root, relative)).isFile()) continue;
290
+ const body = fs.readFileSync(path.join(root, relative), "utf8");
291
+ const owned = ownedFeatureLeaf(relative, body);
292
+ if (owned && !artifacts.has(relative)) obsolete.push({ relative, body });
293
+ }
294
+ const changed = [];
295
+ for (const [relative, body] of artifacts) {
296
+ const previous = validateProjectionTarget(root, relative, body);
297
+ if (previous !== body) changed.push({ relative, body, previous });
298
+ }
299
+ return { changed, obsolete };
300
+ }
@@ -0,0 +1,152 @@
1
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
2
+ // Do not edit directly — durable changes belong upstream in Lisa.
3
+
4
+ /** Portable identities and filesystem boundaries for committed BDD leaves. */
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import { byCodeUnit } from "./contract.mjs";
8
+
9
+ export const FEATURE_PROJECTION_SCHEMA = "lisa-bdd-feature-projection-v1";
10
+ export const PROJECTION_GENERATOR = "lisa-bdd-feature-projection";
11
+ export const PROJECTION_DIRECTORIES = Object.freeze([
12
+ "bdd/reports/v1/features",
13
+ "docs/bdd-scenario-matrix",
14
+ "docs/e2e-bdd-coverage",
15
+ ]);
16
+
17
+ /** Refuse an unsafe or ambiguous plan with an operator-readable diagnosis. */
18
+ export function refuseProjection(message) {
19
+ throw new Error(`BDD projection: ${message}`);
20
+ }
21
+
22
+ /** Validate untrusted relative paths before normalization can hide traversal. */
23
+ export function relativeProjectionPath(value) {
24
+ if (typeof value !== "string" || value.length === 0) {
25
+ refuseProjection("path is missing");
26
+ }
27
+ if (path.isAbsolute(value) || /^[A-Za-z]:/.test(value)) {
28
+ refuseProjection(`path must be repository-relative: ${value}`);
29
+ }
30
+ if (/[/\\]$|\\|[\u0000-\u001f\u007f]/.test(value)) {
31
+ refuseProjection(`unsafe path spelling: ${value}`);
32
+ }
33
+ const parts = value.split("/");
34
+ if (parts.some(part => !part || part === "." || part === "..")) {
35
+ refuseProjection(`path contains empty or traversal components: ${value}`);
36
+ }
37
+ return value.normalize("NFC");
38
+ }
39
+
40
+ /** Full source identity, independent of its display Feature title. */
41
+ export function projectionSourceFile(value) {
42
+ const normalized = relativeProjectionPath(value);
43
+ if (
44
+ !normalized.startsWith("bdd/features/") ||
45
+ !normalized.endsWith(".feature")
46
+ ) {
47
+ refuseProjection(`source must be a bdd/features .feature path: ${value}`);
48
+ }
49
+ return normalized;
50
+ }
51
+
52
+ /** Retain the complete repository-relative source path in every suffix. */
53
+ export function featureArtifactPaths(sourceFile) {
54
+ const source = projectionSourceFile(sourceFile);
55
+ return PROJECTION_DIRECTORIES.map(
56
+ (directory, index) =>
57
+ `${directory}/${source}.${index === 0 ? "json" : "md"}`
58
+ );
59
+ }
60
+
61
+ /** Refuse both leaf and ancestor case/NFC aliases before any mutation. */
62
+ export function validateProjectionCollisions(paths) {
63
+ const spellings = new Map();
64
+ for (const value of paths) {
65
+ relativeProjectionPath(value);
66
+ const components = value.split("/");
67
+ for (let count = 1; count <= components.length; count += 1) {
68
+ const prefix = components.slice(0, count).join("/");
69
+ const key = prefix.normalize("NFC").toLowerCase();
70
+ const previous = spellings.get(key);
71
+ if (previous !== undefined && previous !== prefix) {
72
+ refuseProjection(`case/NFC path collision: ${previous} and ${prefix}`);
73
+ }
74
+ spellings.set(key, prefix);
75
+ }
76
+ }
77
+ }
78
+
79
+ /** A planned file may not also be an ancestor directory of another file. */
80
+ export function validateProjectionTargets(paths) {
81
+ const targets = new Set(paths);
82
+ validateProjectionCollisions(targets);
83
+ for (const relative of targets) {
84
+ const parts = relative.split("/");
85
+ for (let count = 1; count < parts.length; count += 1) {
86
+ const ancestor = parts.slice(0, count).join("/");
87
+ if (targets.has(ancestor)) {
88
+ refuseProjection(
89
+ `planned file/directory collision: ${ancestor} and ${relative}`
90
+ );
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ /** lstat without following a final symlink, including dangling ones. */
97
+ export function projectionStat(absolute) {
98
+ try {
99
+ return fs.lstatSync(absolute);
100
+ } catch (error) {
101
+ if (error.code === "ENOENT") return null;
102
+ throw error;
103
+ }
104
+ }
105
+
106
+ /** Validate every existing ancestor and target, refusing any symlink. */
107
+ export function inspectProjectionPath(root, relative, kind = "file") {
108
+ relativeProjectionPath(relative);
109
+ const parts = relative.split("/");
110
+ let current = root;
111
+ for (let index = 0; index < parts.length; index += 1) {
112
+ current = path.join(current, parts[index]);
113
+ const stat = projectionStat(current);
114
+ if (!stat) return null;
115
+ if (stat.isSymbolicLink()) refuseProjection(`symlink refused: ${relative}`);
116
+ const directory = index < parts.length - 1 || kind === "directory";
117
+ if (directory ? !stat.isDirectory() : !stat.isFile()) {
118
+ refuseProjection(`file/directory or special-file collision: ${relative}`);
119
+ }
120
+ }
121
+ return projectionStat(current);
122
+ }
123
+
124
+ /** List regular leaf candidates without following generated-directory links. */
125
+ export function listProjectionPaths(root, directory) {
126
+ if (!inspectProjectionPath(root, directory, "directory")) return [];
127
+ const result = [];
128
+ const pending = [directory];
129
+ while (pending.length > 0) {
130
+ const current = pending.pop();
131
+ for (const entry of fs.readdirSync(path.join(root, current), {
132
+ withFileTypes: true,
133
+ })) {
134
+ const relative = `${current}/${entry.name}`;
135
+ result.push(relative);
136
+ if (entry.isDirectory()) {
137
+ inspectProjectionPath(root, relative, "directory");
138
+ pending.push(relative);
139
+ } else {
140
+ inspectProjectionPath(root, relative);
141
+ }
142
+ }
143
+ }
144
+ return result.sort(byCodeUnit);
145
+ }
146
+
147
+ /** Preserve raw disk spelling while selecting regular ownership candidates. */
148
+ export function listProjectionFiles(root, directory) {
149
+ return listProjectionPaths(root, directory).filter(relative =>
150
+ projectionStat(path.join(root, relative)).isFile()
151
+ );
152
+ }