@codyswann/lisa 3.69.5 โ†’ 3.70.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 (70) hide show
  1. package/all/copy-overwrite/scripts/lisa-gates.mjs +365 -45
  2. package/all/create-only/.github/workflows/continuous-gates.yml +74 -0
  3. package/cdk/create-only/.github/workflows/deploy.yml +41 -1
  4. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  5. package/dist/core/lisa-owned-hash-ledger.js +10 -0
  6. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +20 -12
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/expo/copy-overwrite/scripts/bdd/contract.mjs +41 -2
  11. package/expo/copy-overwrite/scripts/bdd/envelope.mjs +18 -68
  12. package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +135 -206
  13. package/expo/create-only/.github/workflows/deploy.yml +42 -1
  14. package/expo/create-only/bdd/coverage-map.json +1 -7
  15. package/harper-fabric/create-only/.github/workflows/deploy.yml +37 -0
  16. package/nestjs/create-only/.github/workflows/deploy.yml +44 -1
  17. package/package.json +1 -1
  18. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  20. package/plugins/lisa-agy/plugin.json +1 -1
  21. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  24. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo-agy/plugin.json +1 -1
  31. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  46. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  51. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails-agy/plugin.json +1 -1
  56. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  61. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  66. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  68. package/rails/create-only/.github/workflows/deploy.yml +41 -0
  69. package/scripts/check-learnings-budget.ts +93 -18
  70. package/typescript/github-rulesets/quality-checks.json +4 -0
@@ -60,7 +60,7 @@
60
60
  * @module lisa-gates
61
61
  */
62
62
 
63
- import { existsSync, readFileSync } from "node:fs";
63
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
64
64
  import { join } from "node:path";
65
65
 
66
66
  import { invokedAsScript } from "./lib/invoked-as-script.mjs";
@@ -491,6 +491,26 @@ export const REGISTRY = Object.freeze({
491
491
  moments: PR_ONWARD,
492
492
  work: "routes measured",
493
493
  },
494
+ "behavior-contract": {
495
+ label: "๐Ÿงพ BDD Behavior Contract",
496
+ summary:
497
+ "Every declared behavior is mapped to an automated test, or waived on the record.",
498
+ task: "check:behavior-contract",
499
+ shippedAs: "bdd:coverage",
500
+ declareOnly:
501
+ "The prover ships as `scripts/check-bdd-coverage.mjs`, and the expo stack ships the task that invokes it as `bdd:coverage`, which an expo project resolves through automatically. Elsewhere, point `run:` at your own behavior-contract check.",
502
+ // THE gate for this property, and the only one. The job used to answer to a
503
+ // `bdd_mode` workflow input carrying its own three states, one of which โ€”
504
+ // `bootstrap` โ€” was a time-boxed grace period: a visible, non-blocking check
505
+ // with a named owner and an expiry date. That is `optional` plus paperwork,
506
+ // and it hid red while calling itself adoption. The owner retired it, and
507
+ // the private axis went with it, so the three levels here are the whole
508
+ // vocabulary: `required` to make the context a merge condition, `optional`
509
+ // to see the red without being blocked by it, `off` to say on the record
510
+ // that this project does not govern the property.
511
+ moments: PR_ONWARD,
512
+ work: "scenarios declared",
513
+ },
494
514
  "state-classification": {
495
515
  label: "๐Ÿงฌ State Classification",
496
516
  summary:
@@ -741,6 +761,20 @@ export const REGISTRY = Object.freeze({
741
761
  "Which files are generated is project-specific. Lisa's own prover checks Lisa's own artifacts and would answer nothing useful elsewhere.",
742
762
  moments: COMMIT_ONWARD,
743
763
  },
764
+ "learnings-budget": {
765
+ label: "๐Ÿ“š Learnings Budget",
766
+ summary: "The project learnings ledger stays inside its hard budget.",
767
+ task: "check:learnings-budget",
768
+ declareOnly:
769
+ "Lisa ships the prover as the `lisa check-learnings-budget` CLI subcommand, not as an npm script, so no stack template ships this task. The `๐Ÿ“š Learnings Budget` job runs the CLI itself when nothing is declared; declare `run:` only to point at your own prover.",
770
+ // CORPUS HEALTH, not change correctness: this can fail on a commit that
771
+ // touched nothing near the ledger, because the ledger is a shared document
772
+ // that grows. `artifact-freshness` has the same shape and the same moments,
773
+ // and the reasoning is the same in both โ€” a document that must stay
774
+ // serveable is worth checking at every moment a change is offered, not only
775
+ // at the one where the change happened to touch it.
776
+ moments: COMMIT_ONWARD,
777
+ },
744
778
  "conflict-residue": {
745
779
  label: "๐Ÿฉน Conflict Markers",
746
780
  summary: "No leftover merge-conflict markers in tracked files.",
@@ -815,11 +849,21 @@ export const QUALITY_JOB_GATES = Object.freeze({
815
849
  environment_reset: "environment-reset",
816
850
  environment_reseed: "environment-reseed",
817
851
  dead_code: "dead-code",
852
+ // Governs the property in EVERY workflow that enforces it, which is what
853
+ // #2932 turned on. `quality.yml` and `quality-rails.yml` both run the check
854
+ // in a job named `๐Ÿ“š Learnings Budget`; `plugins-sync.yml` used to run the
855
+ // same command as one step among fifteen inside `๐Ÿงฉ Plugin artifacts match
856
+ // source` โ€” a REQUIRED context the `learnings_budget` skip token could not
857
+ // reach, so the property stayed enforced here no matter what a project
858
+ // declared. That step moved into this job, so one declaration now decides
859
+ // every place the property is proved.
860
+ learnings_budget: "learnings-budget",
818
861
  conflict_markers: "conflict-residue",
819
862
  sg_scan: STRUCTURAL_RULES,
820
863
  npm_security_scan: DEPENDENCY_VULNERABILITY,
821
864
  threshold_ratchet: "threshold-monotonicity",
822
865
  e2e_coverage: "journey-coverage",
866
+ bdd_coverage: "behavior-contract",
823
867
  state_classification: "state-classification",
824
868
  floor_collisions: "security-floor-integrity",
825
869
  // These four could not be wired until their jobs were renamed onto their
@@ -918,20 +962,46 @@ export const SECONDARY_PROVER_JOBS = Object.freeze(["snyk"]);
918
962
  * own expiry rather than becoming permanent by inattention.
919
963
  */
920
964
  export const UNGATED_QUALITY_JOBS = Object.freeze({
921
- bdd_coverage: Object.freeze({
922
- reason:
923
- "The job already has a three-state adoption control, `bdd_mode` (not-adopted / bootstrap / enforced), passed as a workflow input. The registry has three levels (off / optional / required). Whether those are the same three states โ€” and therefore whether `bdd_mode` should collapse into the declaration, or whether `bootstrap` is a genuine fourth level the registry has to grow โ€” is a decision that affects every gate, not this one.",
924
- owner: "#2930",
925
- }),
926
- learnings_budget: Object.freeze({
965
+ zap_baseline: Object.freeze({
927
966
  reason:
928
- "The property is enforced in three workflows and the skip token reaches two of them. The third runs the same command inside a context that is REQUIRED on this repository's ruleset, so a gate that governed only the two would be the same defect one layer up: a declaration satisfied in one workflow and ignored in another.",
929
- owner: "#2932",
967
+ '`runtime-web-vulnerability` names the property, but its legal moments are deploy-only, so there is no declaration a caller can write at pull-request โ€” where this job runs. #2832 built the deploy-moment runner and did NOT retire this: the gate now has an executor at the moments where it is legal, and this job still has none at the moment where it runs. Adding the row today would be worse than the gap. The job posts `๐Ÿ•ท๏ธ OWASP ZAP Baseline`, the gate\'s label is `๐Ÿ•ท๏ธ DAST Baseline`, and `contextsFor` derives `๐Ÿ” Quality Checks / <label>` โ€” so a required declaration would derive a context nothing ever posts and hold every pull request at "Expected โ€” Waiting for status to be reported", here and in every consumer. Closing the gap renames a live required check, which is a ruleset migration and a ruling, not an edit.',
968
+ owner: "#3022",
930
969
  }),
931
- zap_baseline: Object.freeze({
970
+ });
971
+
972
+ /**
973
+ * Gated jobs that ALSO read an adoption input, and why that is not yet fixed.
974
+ *
975
+ * A job with a `QUALITY_JOB_GATES` row has a declaration that decides whether
976
+ * it runs. A job whose `if:` additionally reads a workflow input has a SECOND
977
+ * control, and the two can disagree โ€” which is worse than one control in the
978
+ * wrong place, because the losing one fails silently. `verification_coverage`
979
+ * is that today: it carries the `coverage-adequacy` row and the faรงade, and its
980
+ * `if:` also gates on `verify_enforced`, whose default is `false`. A project
981
+ * declaring `coverage-adequacy: required` at pull-request and leaving the input
982
+ * alone gets no job at all, and its declaration is ignored with no signal.
983
+ *
984
+ * This table exists for the same reason `UNGATED_QUALITY_JOBS` does: the defect
985
+ * is not that the second control exists, it is that nothing SAID SO anywhere a
986
+ * consumer could read. `tests/integration/quality-dual-adoption-controls.test.ts`
987
+ * derives the set from the shipped workflows and refuses a job that is in
988
+ * neither table, so a second one cannot appear in silence โ€” which is the
989
+ * property that survives the individual entries being retired.
990
+ *
991
+ * `owner` is the issue that resolves the entry, so it carries its own expiry.
992
+ *
993
+ * Retiring an entry is NOT a matter of deleting the input. Measured on
994
+ * `verify_enforced` (#3016): with the input gone, the job runs for every
995
+ * consumer and the faรงade's `configured=false` fallback runs a bespoke check
996
+ * most projects will fail. "Not declared" is not "off" โ€” only an explicit `off`
997
+ * skips โ€” so the migration has to give every consumer a way to say `off` first.
998
+ */
999
+ export const DUAL_ADOPTION_CONTROLS = Object.freeze({
1000
+ verification_coverage: Object.freeze({
1001
+ input: "verify_enforced",
932
1002
  reason:
933
- "`runtime-web-vulnerability` names the property, but its legal moments are deploy-only, so there is no declaration a caller can write at pull-request โ€” where this job runs.",
934
- owner: "#2832",
1003
+ "The job carries the `coverage-adequacy` row AND gates on the `verify_enforced` boolean, which defaults to false โ€” so a project that declares the gate `required` still gets no job, and the declaration loses silently. Retiring the input is not a deletion: with it gone the job runs for every consumer and the fallback runs a bespoke check most will fail, because an undeclared gate falls back rather than standing down. The migration has to reach consumers first.",
1004
+ owner: "#3016",
935
1005
  }),
936
1006
  });
937
1007
 
@@ -1150,6 +1220,18 @@ const QUALITY_FALLBACKS = Object.freeze({
1150
1220
  seedRun: [KNIP_CHECK_TASK, KNIP],
1151
1221
  steps: ["๐Ÿ—‘๏ธ Run dead code detection (knip)"],
1152
1222
  },
1223
+ learnings_budget: {
1224
+ // The built-in runs Lisa's own in-tree checker on the Lisa source repo and
1225
+ // the published CLI everywhere else, AT THE PROJECT'S OWN VERSION โ€” read
1226
+ // from its `@codyswann/lisa` dependency range, because the literal that
1227
+ // used to be written here drifted sixty releases behind (#2932). Nothing is
1228
+ // seeded: no npm stack ships a task for this, and a project naming one
1229
+ // would replace a branch that also decides WHICH prover to run.
1230
+ command:
1231
+ "bun scripts/check-learnings-budget.ts (Lisa source) or bunx @codyswann/lisa@<project version> check-learnings-budget",
1232
+ seedRun: [],
1233
+ steps: ["๐Ÿ“š Check learnings budget"],
1234
+ },
1153
1235
  conflict_markers: {
1154
1236
  // The built-in resolves the shipped `check-conflict-markers.mjs` from
1155
1237
  // whichever of three locations the project has it in, so the command is a
@@ -1205,6 +1287,21 @@ const QUALITY_FALLBACKS = Object.freeze({
1205
1287
  "โญ๏ธ Skip e2e coverage (no check-e2e-coverage.mjs script)",
1206
1288
  ],
1207
1289
  },
1290
+ bdd_coverage: {
1291
+ // The one faรงade job with NO written-in command. Every other entry here
1292
+ // records what runs when nothing declares the gate; this job runs nothing,
1293
+ // because its prover ships to every project on the stack and a fallback
1294
+ // would enforce a behavior contract on every consumer that never adopted
1295
+ // one. Recorded anyway, and with the same shape as the environment faรงade
1296
+ // above: an inventory that omitted it would read as an oversight, and the
1297
+ // stand-down step is what an operator needs pointed at.
1298
+ command: "(none โ€” the job stands down when the gate is undeclared)",
1299
+ // The expo stack ships the prover as , which is what a
1300
+ // declaration should point at; the registry default names the
1301
+ // concern and resolves nowhere.
1302
+ seedRun: ["bdd:coverage"],
1303
+ steps: ["โญ๏ธ Stand down (behavior-contract is not declared)"],
1304
+ },
1208
1305
  state_classification: {
1209
1306
  command:
1210
1307
  "node scripts/check-state-classification.mjs (when the script exists)",
@@ -2416,7 +2513,11 @@ export const DEFAULT_RUNNER = "npm run";
2416
2513
  * This is ONE control for the whole family, deliberately. `bdd_coverage`
2417
2514
  * hand-rolled a private three-state input (`bdd_mode`) for the same decision;
2418
2515
  * repeating that six more times would be six more adoption operations and six
2419
- * more places to drift.
2516
+ * more places to drift. That input is now retired and `bdd_coverage` answers to
2517
+ * the `behavior-contract` declaration like everything else โ€” but it does NOT
2518
+ * join this family, because it has no absent-script path to fall back from: its
2519
+ * prover ships to every project on the stack, so an undeclared gate there would
2520
+ * mean enforcing a contract nobody adopted. It stands down unless declared.
2420
2521
  *
2421
2522
  * `warn` is the default and reproduces today's behaviour exactly, because
2422
2523
  * making absence fatal before a declaration is guaranteed turns every
@@ -2727,14 +2828,162 @@ export const EXECUTOR_VERDICTS = Object.freeze([
2727
2828
  */
2728
2829
  const BLOCKING_VERDICTS = Object.freeze([ORPHANED, VACUOUS_PROVER]);
2729
2830
 
2730
- /** Moment families whose executor is the generic hook runner. */
2731
- const HOOK_RUNNER_MOMENTS = Object.freeze([
2732
- SESSION_START,
2733
- PRE_TOOL,
2734
- POST_TOOL,
2735
- COMMIT,
2736
- PUSH,
2737
- ]);
2831
+ /** Where a repository declares which moments it runs gates at. */
2832
+ export const MOMENT_EXECUTOR_DIR = ".github/workflows";
2833
+
2834
+ /**
2835
+ * A moment handed to a gate runner, in either spelling a workflow uses.
2836
+ *
2837
+ * `moment: <value>` is how a caller parameterises a reusable workflow, and
2838
+ * `--moment=<value>` is how a shell step invokes `lisa-run-gates.mjs` directly.
2839
+ * Both are the same statement โ€” "this workflow executes gates at this moment" โ€”
2840
+ * and a scan that read only one of them would report a repository as having no
2841
+ * runner while looking straight at one.
2842
+ *
2843
+ * `[ \t]*` rather than `\s*` around the colon, deliberately. A workflow that
2844
+ * DEFINES a `moment:` input writes the key with its value on following lines,
2845
+ * and `\s*` would step over the newline and capture `description` as the
2846
+ * moment. The definition of an input is not the use of one.
2847
+ *
2848
+ * The backtick is excluded from the unquoted form for the same class of reason,
2849
+ * and it was measured rather than anticipated: the header of Lisa's own
2850
+ * `gates.yml` documents the defect it fixes by quoting
2851
+ * ``list --moment=pre-deploy:production``, and the first version of this scan
2852
+ * read that PROSE as a runner and reported the moment executed. A workflow
2853
+ * describing a command is not a workflow running one.
2854
+ */
2855
+ const MOMENT_ARGUMENT =
2856
+ /(?:\bmoment[ \t]*:[ \t]*|--moment=)(?:'([^'\n]+)'|"([^"\n]+)"|([^\s'"#`]+))/g;
2857
+
2858
+ /** A line that is entirely a comment โ€” YAML's and the shell's are both `#`. */
2859
+ const COMMENT_LINE = /^\s*#/;
2860
+
2861
+ /** The answer when nothing has been measured: no moment is executed. */
2862
+ const NOTHING_EXECUTED = Object.freeze({
2863
+ moments: Object.freeze([]),
2864
+ families: Object.freeze([]),
2865
+ });
2866
+
2867
+ /**
2868
+ * Which moments THIS repository actually runs gates at, read off its workflows.
2869
+ *
2870
+ * The registry's `no-runner-for-moment` verdict used to be an ASSERTION: every
2871
+ * declaration in a deploy or continuous family got told "nothing runs gates
2872
+ * here at all yet", because at the time nothing did. That sentence is a fact
2873
+ * about the repository, not about the gate, and hardcoding it has two failure
2874
+ * modes that arrive together. It keeps saying "nothing runs this" after a runner
2875
+ * ships โ€” a control lying in the reassuring direction. And it keeps EXCUSING a
2876
+ * declaration that resolves to no prover, calling it inert-but-fine, at exactly
2877
+ * the moment the runner would have executed it and reported UNPROVABLE.
2878
+ *
2879
+ * So it is measured instead. A repository executes a moment when one of its
2880
+ * workflows names that moment to a gate runner.
2881
+ *
2882
+ * ## Family, when the environment is computed
2883
+ *
2884
+ * A deploy caller writes `moment: pre-deploy:${{ ... }}` โ€” the family is
2885
+ * literal and the environment is a run-time expression. Requiring an exact
2886
+ * match would report "no runner" for every real deploy faรงade there is, so a
2887
+ * computed environment registers the FAMILY, and any moment in it counts as
2888
+ * executed. A value with no readable family (`moment: ${{ inputs.moment }}`,
2889
+ * which is how a reusable workflow forwards its own input) contributes nothing:
2890
+ * it is plumbing between two workflows, and the caller at the other end is the
2891
+ * one that made a statement about a moment.
2892
+ *
2893
+ * ## An unreadable directory answers "nothing", and that is the honest answer
2894
+ *
2895
+ * A repository with no `.github/workflows` runs no workflows, so no workflow of
2896
+ * its executes any moment. That is not a guess made under uncertainty; it is
2897
+ * the measurement.
2898
+ * @param {object} [options] Inputs.
2899
+ * @param {string} [options.cwd] Repository root.
2900
+ * @param {string} [options.dir] Directory holding the workflows.
2901
+ * @returns {{moments: string[], families: string[]}} Exact moments executed, and families executed at a computed environment.
2902
+ */
2903
+ export function momentsExecutedBy({
2904
+ cwd = process.cwd(),
2905
+ dir = MOMENT_EXECUTOR_DIR,
2906
+ } = {}) {
2907
+ const moments = new Set();
2908
+ const families = new Set();
2909
+ const root = join(cwd, dir);
2910
+ let entries;
2911
+ try {
2912
+ entries = readdirSync(root);
2913
+ } catch {
2914
+ return NOTHING_EXECUTED;
2915
+ }
2916
+ for (const entry of entries) {
2917
+ if (!entry.endsWith(".yml") && !entry.endsWith(".yaml")) continue;
2918
+ let text;
2919
+ try {
2920
+ text = readFileSync(join(root, entry), "utf8");
2921
+ } catch {
2922
+ continue;
2923
+ }
2924
+ for (const value of momentArguments(text)) {
2925
+ recordExecutedMoment(value, moments, families);
2926
+ }
2927
+ }
2928
+ return {
2929
+ moments: [...moments].sort(),
2930
+ families: [...families].sort(),
2931
+ };
2932
+ }
2933
+
2934
+ /**
2935
+ * Every moment argument one workflow file names.
2936
+ * @param {string} text The workflow source.
2937
+ * @returns {string[]} The raw values, unvalidated.
2938
+ */
2939
+ function momentArguments(text) {
2940
+ const found = [];
2941
+ // Line by line, so a whole-line comment can be dropped before it is read.
2942
+ // Both languages in a workflow file โ€” YAML and the shell inside `run:` โ€”
2943
+ // comment with `#`, so one rule covers both, and a header documenting the
2944
+ // very command this scan looks for stops counting as an invocation of it.
2945
+ for (const line of text.split("\n")) {
2946
+ if (COMMENT_LINE.test(line)) continue;
2947
+ for (const match of line.matchAll(MOMENT_ARGUMENT)) {
2948
+ const value = match[1] ?? match[2] ?? match[3];
2949
+ if (value) found.push(value.trim());
2950
+ }
2951
+ }
2952
+ return found;
2953
+ }
2954
+
2955
+ /**
2956
+ * Record one workflow's moment argument against the exact/family sets.
2957
+ * @param {string} value The raw value read from the workflow.
2958
+ * @param {Set<string>} moments Exact moments, mutated in place.
2959
+ * @param {Set<string>} families Families executed at a computed environment.
2960
+ */
2961
+ function recordExecutedMoment(value, moments, families) {
2962
+ if (MOMENTS.includes(value)) {
2963
+ moments.add(value);
2964
+ return;
2965
+ }
2966
+ const separator = value.indexOf(":");
2967
+ if (separator === -1) return;
2968
+ const family = value.slice(0, separator);
2969
+ const environment = value.slice(separator + 1);
2970
+ if (!MOMENT_FAMILIES.includes(family) || environment === "") return;
2971
+ // `${{ ... }}` and `$VAR` both mean "decided at run time". The family is
2972
+ // still a statement, and it is the only one available.
2973
+ if (environment.includes("$")) families.add(family);
2974
+ else moments.add(`${family}:${environment}`);
2975
+ }
2976
+
2977
+ /**
2978
+ * Whether the measured inventory covers one declared moment.
2979
+ * @param {{moments?: string[], families?: string[]}} executed The inventory.
2980
+ * @param {string} moment The declared moment.
2981
+ * @returns {boolean} Whether something in the repository runs gates there.
2982
+ */
2983
+ function executesMoment(executed, moment) {
2984
+ const { moments = [], families = [] } = executed ?? {};
2985
+ return moments.includes(moment) || families.includes(momentFamily(moment));
2986
+ }
2738
2987
 
2739
2988
  /**
2740
2989
  * Gates whose only prover reports findings and cannot fail.
@@ -2792,15 +3041,44 @@ function declaredLevel(entry) {
2792
3041
  }
2793
3042
 
2794
3043
  /**
2795
- * The task one gate resolves to, honouring a `run:` override.
2796
- * @param {string} gate Gate id.
2797
- * @param {object} block The gate's own block from the config.
2798
- * @returns {string|undefined} The task name.
3044
+ * The task one declaration resolves to, by the SAME order `resolveMoment` uses.
3045
+ *
3046
+ * It has to be the same order or the classifier judges a command the runner
3047
+ * would never issue. The five sources, narrowest first: the moment entry's own
3048
+ * `run:`, the gate block's `run:`, the `shippedAs` alias when the registry
3049
+ * default is absent and the alias is present, the moment family's `taskAt`
3050
+ * default, and finally the plain registry `task`.
3051
+ *
3052
+ * The alias matters most here and is the easiest to omit. `security:dast` is the
3053
+ * registry name for a dynamic scan and NO stack ships a script under it; two
3054
+ * stacks ship `security:zap`, and the runner substitutes it. A classifier that
3055
+ * looked only at `task` would call every real DAST declaration an orphan and
3056
+ * refuse a configuration that works.
3057
+ * @param {object} options Inputs.
3058
+ * @param {string} options.gate Gate id.
3059
+ * @param {object} options.block The gate's own block from the config.
3060
+ * @param {string} options.moment The declared moment.
3061
+ * @param {Record<string, string>} options.scripts The project's package scripts.
3062
+ * @returns {string|null|undefined} The task the runner would run.
2799
3063
  */
2800
- function declaredTask(gate, block) {
2801
- const override =
2802
- block !== null && typeof block === "object" ? block.run : undefined;
2803
- return typeof override === "string" ? override : REGISTRY[gate]?.task;
3064
+ function resolvedTaskFor({ gate, block, moment, scripts }) {
3065
+ const definition = REGISTRY[gate];
3066
+ const entry = block?.[moment];
3067
+ const entryRun =
3068
+ entry !== null && typeof entry === "object" && typeof entry.run === "string"
3069
+ ? entry.run
3070
+ : null;
3071
+ const blockRun = typeof block?.run === "string" ? block.run : null;
3072
+ const declared = entryRun ?? blockRun;
3073
+ const registryTask =
3074
+ definition?.taskAt?.[momentFamily(moment)] ?? definition?.task ?? null;
3075
+ const alias = aliasFor({
3076
+ declared,
3077
+ registryTask,
3078
+ shippedAs: definition?.shippedAs ?? null,
3079
+ scripts,
3080
+ });
3081
+ return declared ?? alias?.to ?? registryTask;
2804
3082
  }
2805
3083
 
2806
3084
  /**
@@ -2837,6 +3115,7 @@ export function classifyDeclaredExecutors({
2837
3115
  gates,
2838
3116
  scripts,
2839
3117
  outsideFacade = PROVED_OUTSIDE_FACADE,
3118
+ executedMoments = NOTHING_EXECUTED,
2840
3119
  }) {
2841
3120
  const findings = [];
2842
3121
  for (const [gate, block] of Object.entries(gates ?? {})) {
@@ -2860,6 +3139,7 @@ export function classifyDeclaredExecutors({
2860
3139
  level,
2861
3140
  scripts,
2862
3141
  outsideFacade,
3142
+ executedMoments,
2863
3143
  });
2864
3144
  if (finding !== null) findings.push(finding);
2865
3145
  }
@@ -2876,9 +3156,18 @@ export function classifyDeclaredExecutors({
2876
3156
  * @param {string} options.level Declared level.
2877
3157
  * @param {Record<string, string>|null|undefined} options.scripts Project scripts.
2878
3158
  * @param {Record<string, string>} options.outsideFacade Gates proved outside the faรงade.
3159
+ * @param {{moments?: string[], families?: string[]}} options.executedMoments Moments this repository actually runs gates at.
2879
3160
  * @returns {{gate: string, moment: string, level: string, verdict: string, detail: string}|null} The finding.
2880
3161
  */
2881
- function verdictFor({ gate, block, moment, level, scripts, outsideFacade }) {
3162
+ function verdictFor({
3163
+ gate,
3164
+ block,
3165
+ moment,
3166
+ level,
3167
+ scripts,
3168
+ outsideFacade,
3169
+ executedMoments,
3170
+ }) {
2882
3171
  const make = (verdict, detail) => ({ gate, moment, level, verdict, detail });
2883
3172
  if (level === "required" && Object.hasOwn(ADVISORY_PROVERS, gate)) {
2884
3173
  return make(VACUOUS_PROVER, ADVISORY_PROVERS[gate]);
@@ -2892,17 +3181,6 @@ function verdictFor({ gate, block, moment, level, scripts, outsideFacade }) {
2892
3181
  );
2893
3182
  }
2894
3183
  const family = momentFamily(moment);
2895
- if (HOOK_RUNNER_MOMENTS.includes(family)) {
2896
- if (scripts === null || scripts === undefined) return null;
2897
- const task = declaredTask(gate, block);
2898
- if (task !== undefined && Object.hasOwn(scripts, task)) return null;
2899
- return make(
2900
- ORPHANED,
2901
- `gates."${gate}"."${moment}" resolves to the task "${task}", and this ` +
2902
- `project has no such script. The hook runner would find nothing to ` +
2903
- `run, so the level in front of it describes nothing.`
2904
- );
2905
- }
2906
3184
  if (family === PULL_REQUEST) {
2907
3185
  if (Object.values(QUALITY_JOB_GATES).includes(gate)) return null;
2908
3186
  return make(
@@ -2914,10 +3192,44 @@ function verdictFor({ gate, block, moment, level, scripts, outsideFacade }) {
2914
3192
  `reported" indefinitely.`
2915
3193
  );
2916
3194
  }
3195
+ // The deploy and continuous families have a generic runner the same way the
3196
+ // hooks do โ€” `lisa-run-gates.mjs --moment=<moment>` โ€” but unlike a hook,
3197
+ // which every repository installs, the CALLER is a workflow this repository
3198
+ // either has or does not. So the question is asked of the repository rather
3199
+ // than answered from a table: is there a workflow that hands this moment to
3200
+ // the runner?
3201
+ if (
3202
+ MOMENT_FAMILIES.includes(family) &&
3203
+ !executesMoment(executedMoments, moment)
3204
+ ) {
3205
+ return make(
3206
+ NO_RUNNER_FOR_MOMENT,
3207
+ `nothing in this repository runs gates at "${moment}": no workflow ` +
3208
+ `under ${MOMENT_EXECUTOR_DIR} hands that moment to Lisa's gate ` +
3209
+ `runner. The declaration is read, validated and listed, and then ` +
3210
+ `never executed โ€” inert rather than wrong. Add a caller for Lisa's ` +
3211
+ `gates.yml at this moment (the shipped deploy.yml and ` +
3212
+ `continuous-gates.yml templates do exactly that), or declare it ` +
3213
+ `"off" to put on record that you meant it not to run.`
3214
+ );
3215
+ }
3216
+ // Everything remaining is executed by the generic runner: a hook at its
3217
+ // moment, or a workflow at a deploy or scheduled one. Both resolve a task and
3218
+ // run it, so both need a task that exists.
3219
+ if (scripts === null || scripts === undefined) return null;
3220
+ const task = resolvedTaskFor({ gate, block, moment, scripts });
3221
+ if (
3222
+ task !== undefined &&
3223
+ task !== null &&
3224
+ Object.hasOwn(scripts ?? {}, task)
3225
+ ) {
3226
+ return null;
3227
+ }
2917
3228
  return make(
2918
- NO_RUNNER_FOR_MOMENT,
2919
- `nothing runs gates at "${moment}" at all yet, so this declaration is ` +
2920
- `inert rather than wrong. A different defect, tracked separately.`
3229
+ ORPHANED,
3230
+ `gates."${gate}"."${moment}" resolves to the task "${task}", and this ` +
3231
+ `project has no such script. The gate runner would find nothing to ` +
3232
+ `run, so the level in front of it describes nothing.`
2921
3233
  );
2922
3234
  }
2923
3235
 
@@ -3776,7 +4088,15 @@ function main() {
3776
4088
  // suppress "configuration is valid" for every project that ever runs this,
3777
4089
  // which turns the verdict into noise and trains an operator to ignore both.
3778
4090
  reportUngoverned(gates);
3779
- const executors = classifyDeclaredExecutors({ gates, scripts });
4091
+ // MEASURED, not assumed. Whether a deploy or continuous declaration has
4092
+ // anything able to run it is a fact about THIS repository's workflows, and
4093
+ // the answer changed the day a deploy faรงade shipped. Reading it here means
4094
+ // the report cannot go stale in the reassuring direction.
4095
+ const executors = classifyDeclaredExecutors({
4096
+ gates,
4097
+ scripts,
4098
+ executedMoments: momentsExecutedBy(),
4099
+ });
3780
4100
  // BLOCKING, and this is the change #2843 called "the substantive work".
3781
4101
  // The classifier existed as a vitest suite reading THIS repository's own
3782
4102
  // config โ€” so the check existed for Lisa and did not exist for anyone Lisa
@@ -0,0 +1,74 @@
1
+ ---
2
+ # Seeded by Lisa on first setup โ€” this file is YOURS.
3
+ # Lisa will not overwrite it. (copy-overwrite assets ARE replaced each run.)
4
+ #
5
+ # The `continuous:<environment>` moment, on a schedule.
6
+ #
7
+ # Every other gate moment blocks a DIFF โ€” the commit, the push, the merge, the
8
+ # deploy. A continuous gate has no diff to block: it runs against a stable
9
+ # target, and by the time it fails, whatever it covered merged hours ago. What it
10
+ # establishes is whether that target is HEALTHY, and the enforcement point is
11
+ # promotion out of it โ€” a red `continuous:staging` means staging is not
12
+ # promotable, which a `pre-deploy:production` gate can require.
13
+ #
14
+ # The clearest case is a CVE: one published today makes yesterday's green wrong
15
+ # with no change to trigger a re-scan. A per-change gate cannot catch that,
16
+ # because there is no change. Only a scheduled run can.
17
+ #
18
+ # ## Nothing runs until you declare something
19
+ #
20
+ # This workflow runs exactly what the `gates` block of `.lisa.config.json`
21
+ # declares at `continuous:<environment>`, and nothing else. Out of the box that
22
+ # is nothing, and the run says so rather than reporting a green that implies a
23
+ # proof. To use it, declare a gate at this moment:
24
+ #
25
+ # ```json
26
+ # "gates": {
27
+ # "dependency-vulnerability": { "continuous:production": "required" },
28
+ # "runtime-web-vulnerability": { "continuous:production": "required" }
29
+ # }
30
+ # ```
31
+ #
32
+ # Eleven of Lisa's gates are legal here; `lisa-gates.mjs list
33
+ # --moment=continuous:production` prints the ones this project can use, and
34
+ # `validate` tells you when a declaration resolves to no prover.
35
+ #
36
+ # ## Customise the environment and the cadence
37
+ #
38
+ # `ENVIRONMENT` below is the suffix of the moment, so it must match what you
39
+ # write in `.lisa.config.json`. Change the cron to suit โ€” a load profile nightly
40
+ # and a dependency scan hourly are two workflows, not one, and splitting them is
41
+ # cheaper than running the expensive one twelve times a day.
42
+ name: ๐ŸŒ™ Continuous Gates
43
+
44
+ on:
45
+ schedule:
46
+ # 07:00 UTC daily.
47
+ - cron: '0 7 * * *'
48
+ workflow_dispatch:
49
+ inputs:
50
+ environment:
51
+ description: 'Environment whose continuous gates to run.'
52
+ required: false
53
+ default: 'production'
54
+ type: string
55
+
56
+ permissions:
57
+ contents: read
58
+
59
+ # One run per environment. A second scheduled run arriving while the first is
60
+ # still going would prove the same property about the same target twice, and the
61
+ # provers here โ€” a DAST scan, a load profile โ€” are the ones that mind.
62
+ concurrency:
63
+ group: continuous-gates-${{ inputs.environment || 'production' }}
64
+ cancel-in-progress: false
65
+
66
+ jobs:
67
+ continuous:
68
+ name: ๐ŸŒ™ Continuous Gates
69
+ uses: CodySwannGT/lisa/.github/workflows/gates.yml@main
70
+ with:
71
+ moment: continuous:${{ inputs.environment || 'production' }}
72
+ node_version: '22.21.1'
73
+ package_manager: 'npm'
74
+ secrets: inherit
@@ -64,11 +64,36 @@ jobs:
64
64
  echo "approval_environment=$APPROVAL_ENV" >> $GITHUB_OUTPUT
65
65
  echo "require_approval=$REQUIRE" >> $GITHUB_OUTPUT
66
66
 
67
+ # โ”€โ”€ Pre-deploy gates โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
68
+ # Resolves `pre-deploy:<environment>` from the `gates` block of
69
+ # .lisa.config.json and runs whatever is declared there, BEFORE the release.
70
+ #
71
+ # This is what makes a declaration like
72
+ #
73
+ # "runtime-web-vulnerability": { "pre-deploy:production": "required" }
74
+ #
75
+ # mean something. A required gate that fails โ€” or that resolves to no prover at
76
+ # all, which is UNPROVABLE rather than passing โ€” stops the workflow here, so
77
+ # the release is never cut. A gate job running BESIDE the deploy would go red
78
+ # after the tag and the artefacts had already shipped, which is a report.
79
+ #
80
+ # A project that declares nothing at this moment gets a green job that ran no
81
+ # gates and says so, and the deploy behaves exactly as it did before.
82
+ pre_deploy_gates:
83
+ name: ๐Ÿšฆ Pre-Deploy Gates
84
+ uses: CodySwannGT/lisa/.github/workflows/gates.yml@main
85
+ needs: [determine_environment]
86
+ with:
87
+ moment: pre-deploy:${{ needs.determine_environment.outputs.environment }}
88
+ node_version: '22.21.1'
89
+ package_manager: 'npm'
90
+ secrets: inherit
91
+
67
92
  release:
68
93
  name: ๐Ÿ“ฆ Release
69
94
  # Reference to the quality checks workflow
70
95
  uses: CodySwannGT/lisa/.github/workflows/release.yml@main
71
- needs: [determine_environment]
96
+ needs: [determine_environment, pre_deploy_gates]
72
97
  permissions:
73
98
  contents: write
74
99
  pull-requests: read
@@ -91,4 +116,19 @@ jobs:
91
116
  SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
92
117
  SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
93
118
  SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
119
+
120
+ # โ”€โ”€ Post-deploy gates โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
121
+ # Proves a property of what is now live. A red here does not un-ship the
122
+ # release; it names the gate that does not hold so an operator can act on it,
123
+ # which is the difference between a post-deploy gate and a post-deploy log.
124
+ post_deploy_gates:
125
+ name: ๐Ÿšฆ Post-Deploy Gates
126
+ uses: CodySwannGT/lisa/.github/workflows/gates.yml@main
127
+ needs: [determine_environment, release]
128
+ if: ${{ always() && !cancelled() && needs.release.result == 'success' }}
129
+ with:
130
+ moment: post-deploy:${{ needs.determine_environment.outputs.environment }}
131
+ node_version: '22.21.1'
132
+ package_manager: 'npm'
133
+ secrets: inherit
94
134
  # Trigger staging deployment after CDK trust fix
@@ -1 +1 @@
1
- {"version":3,"file":"lisa-owned-hash-ledger.d.ts","sourceRoot":"","sources":["../../src/core/lisa-owned-hash-ledger.ts"],"names":[],"mappings":"AACA,6EAA6E;AAE7E;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,EAAE,QAAQ,CAC3C,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA8hBjC,CAAC"}
1
+ {"version":3,"file":"lisa-owned-hash-ledger.d.ts","sourceRoot":"","sources":["../../src/core/lisa-owned-hash-ledger.ts"],"names":[],"mappings":"AACA,6EAA6E;AAE7E;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,EAAE,QAAQ,CAC3C,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAwiBjC,CAAC"}