@codyswann/lisa 3.69.6 → 3.70.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 (80) hide show
  1. package/all/copy-overwrite/scripts/lisa-gates.mjs +254 -35
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +224 -7
  3. package/all/create-only/.github/workflows/continuous-gates.yml +74 -0
  4. package/cdk/create-only/.github/workflows/deploy.yml +41 -1
  5. package/dist/codex/scripts/block-no-verify.sh +190 -3
  6. package/dist/configs/vitest/scratch-global-setup.d.ts +32 -0
  7. package/dist/configs/vitest/scratch-global-setup.d.ts.map +1 -1
  8. package/dist/configs/vitest/scratch-global-setup.js +48 -0
  9. package/dist/configs/vitest/scratch-global-setup.js.map +1 -1
  10. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  11. package/dist/core/lisa-owned-hash-ledger.js +3 -0
  12. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  13. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  14. package/dist/core/upstream-evidence-manifest.js +18 -9
  15. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  16. package/dist/opencode/hooks-installer.d.ts.map +1 -1
  17. package/dist/opencode/hooks-installer.js +14 -0
  18. package/dist/opencode/hooks-installer.js.map +1 -1
  19. package/expo/create-only/.github/workflows/deploy.yml +42 -1
  20. package/harper-fabric/create-only/.github/workflows/deploy.yml +37 -0
  21. package/nestjs/create-only/.github/workflows/deploy.yml +44 -1
  22. package/package.json +1 -1
  23. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  25. package/plugins/lisa/hooks/block-no-verify.agy.sh +200 -6
  26. package/plugins/lisa/hooks/block-no-verify.sh +224 -7
  27. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +200 -6
  28. package/plugins/lisa-agy/plugin.json +1 -1
  29. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  31. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  32. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-copilot/hooks/block-no-verify.sh +224 -7
  36. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-cursor/hooks/block-no-verify.sh +224 -7
  38. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-expo-agy/plugin.json +1 -1
  41. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  51. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  56. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  60. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  61. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  65. package/plugins/lisa-rails-agy/plugin.json +1 -1
  66. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  70. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  71. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  75. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  76. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  78. package/plugins/src/base/hooks/block-no-verify.agy.sh +200 -6
  79. package/plugins/src/base/hooks/block-no-verify.sh +224 -7
  80. package/rails/create-only/.github/workflows/deploy.yml +41 -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";
@@ -964,8 +964,8 @@ export const SECONDARY_PROVER_JOBS = Object.freeze(["snyk"]);
964
964
  export const UNGATED_QUALITY_JOBS = Object.freeze({
965
965
  zap_baseline: Object.freeze({
966
966
  reason:
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.",
968
- owner: "#2832",
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",
969
969
  }),
970
970
  });
971
971
 
@@ -2828,14 +2828,162 @@ export const EXECUTOR_VERDICTS = Object.freeze([
2828
2828
  */
2829
2829
  const BLOCKING_VERDICTS = Object.freeze([ORPHANED, VACUOUS_PROVER]);
2830
2830
 
2831
- /** Moment families whose executor is the generic hook runner. */
2832
- const HOOK_RUNNER_MOMENTS = Object.freeze([
2833
- SESSION_START,
2834
- PRE_TOOL,
2835
- POST_TOOL,
2836
- COMMIT,
2837
- PUSH,
2838
- ]);
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
+ }
2839
2987
 
2840
2988
  /**
2841
2989
  * Gates whose only prover reports findings and cannot fail.
@@ -2893,15 +3041,44 @@ function declaredLevel(entry) {
2893
3041
  }
2894
3042
 
2895
3043
  /**
2896
- * The task one gate resolves to, honouring a `run:` override.
2897
- * @param {string} gate Gate id.
2898
- * @param {object} block The gate's own block from the config.
2899
- * @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.
2900
3063
  */
2901
- function declaredTask(gate, block) {
2902
- const override =
2903
- block !== null && typeof block === "object" ? block.run : undefined;
2904
- 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;
2905
3082
  }
2906
3083
 
2907
3084
  /**
@@ -2938,6 +3115,7 @@ export function classifyDeclaredExecutors({
2938
3115
  gates,
2939
3116
  scripts,
2940
3117
  outsideFacade = PROVED_OUTSIDE_FACADE,
3118
+ executedMoments = NOTHING_EXECUTED,
2941
3119
  }) {
2942
3120
  const findings = [];
2943
3121
  for (const [gate, block] of Object.entries(gates ?? {})) {
@@ -2961,6 +3139,7 @@ export function classifyDeclaredExecutors({
2961
3139
  level,
2962
3140
  scripts,
2963
3141
  outsideFacade,
3142
+ executedMoments,
2964
3143
  });
2965
3144
  if (finding !== null) findings.push(finding);
2966
3145
  }
@@ -2977,9 +3156,18 @@ export function classifyDeclaredExecutors({
2977
3156
  * @param {string} options.level Declared level.
2978
3157
  * @param {Record<string, string>|null|undefined} options.scripts Project scripts.
2979
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.
2980
3160
  * @returns {{gate: string, moment: string, level: string, verdict: string, detail: string}|null} The finding.
2981
3161
  */
2982
- 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
+ }) {
2983
3171
  const make = (verdict, detail) => ({ gate, moment, level, verdict, detail });
2984
3172
  if (level === "required" && Object.hasOwn(ADVISORY_PROVERS, gate)) {
2985
3173
  return make(VACUOUS_PROVER, ADVISORY_PROVERS[gate]);
@@ -2993,17 +3181,6 @@ function verdictFor({ gate, block, moment, level, scripts, outsideFacade }) {
2993
3181
  );
2994
3182
  }
2995
3183
  const family = momentFamily(moment);
2996
- if (HOOK_RUNNER_MOMENTS.includes(family)) {
2997
- if (scripts === null || scripts === undefined) return null;
2998
- const task = declaredTask(gate, block);
2999
- if (task !== undefined && Object.hasOwn(scripts, task)) return null;
3000
- return make(
3001
- ORPHANED,
3002
- `gates."${gate}"."${moment}" resolves to the task "${task}", and this ` +
3003
- `project has no such script. The hook runner would find nothing to ` +
3004
- `run, so the level in front of it describes nothing.`
3005
- );
3006
- }
3007
3184
  if (family === PULL_REQUEST) {
3008
3185
  if (Object.values(QUALITY_JOB_GATES).includes(gate)) return null;
3009
3186
  return make(
@@ -3015,10 +3192,44 @@ function verdictFor({ gate, block, moment, level, scripts, outsideFacade }) {
3015
3192
  `reported" indefinitely.`
3016
3193
  );
3017
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
+ }
3018
3228
  return make(
3019
- NO_RUNNER_FOR_MOMENT,
3020
- `nothing runs gates at "${moment}" at all yet, so this declaration is ` +
3021
- `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.`
3022
3233
  );
3023
3234
  }
3024
3235
 
@@ -3877,7 +4088,15 @@ function main() {
3877
4088
  // suppress "configuration is valid" for every project that ever runs this,
3878
4089
  // which turns the verdict into noise and trains an operator to ignore both.
3879
4090
  reportUngoverned(gates);
3880
- 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
+ });
3881
4100
  // BLOCKING, and this is the change #2843 called "the substantive work".
3882
4101
  // The classifier existed as a vitest suite reading THIS repository's own
3883
4102
  // config — so the check existed for Lisa and did not exist for anyone Lisa
@@ -12,7 +12,9 @@
12
12
  # 2. HUSKY=0 / HUSKY_SKIP_HOOKS=... — disables husky-managed git hooks;
13
13
  # 3. core.hooksPath pointed anywhere but an allowlisted in-repo hooks dir;
14
14
  # 4. --config-env=core.hooksPath=VAR, in both the one- and two-token spellings;
15
- # 5. GIT_CONFIG_KEY_<n>=core.hooksPath env-var-style command-scope config.
15
+ # 5. GIT_CONFIG_KEY_<n>=core.hooksPath env-var-style command-scope config;
16
+ # 6. the short `-n`, as a real argv token of a `git commit` — bare, or bundled
17
+ # into a short-option cluster such as `-nm "msg"`.
16
18
  #
17
19
  # The line below is what lets `lisa apply` tell a downstream copy of this guard
18
20
  # that is BEHIND from one that is AHEAD. Byte comparison cannot: both look like
@@ -23,15 +25,31 @@
23
25
  # Add a name here in the same commit that closes a vector. A hardening that
24
26
  # forgets to is invisible to refresh, and shows up as an unexplained diff at
25
27
  # review time instead of a named capability.
26
- # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, git-config-key
28
+ # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, git-config-key, no-verify-short
27
29
  #
28
30
  # Shell-token matching avoids false positives from issue bodies, heredocs, and
29
31
  # commit-message prose while still catching quoted real argv values such as
30
32
  # `git -c "core.hooksPath=/dev/null"`.
31
33
  #
32
- # The short `-n` form is intentionally NOT matched (see block-no-verify.agy.sh):
33
- # grep cannot distinguish a real -n option from -n in commit-message prose or an
34
- # unrelated piped command, and -n is far more common than --no-verify.
34
+ # The short `-n` form used to be excluded here, and the stated reason was that
35
+ # "grep cannot distinguish a real -n option from -n in commit-message prose or
36
+ # an unrelated piped command". That was true of the matcher it was written for.
37
+ # It is not true of this one: the command is TOKENIZED, not grepped, so `-m "fix
38
+ # the -n flag"` is one token holding the message and `grep -n` is a separate
39
+ # command — the two cases the comment called inseparable are separated by the
40
+ # same machinery `--config-env` and `GIT_CONFIG_KEY_<n>` already depend on.
41
+ #
42
+ # A rationale that outlives the implementation it describes is worse than none:
43
+ # it reads as a considered decision and stops the next reader from re-examining
44
+ # it. Measured against real git, `git commit -n`, `-nm msg`, and `-anm msg` all
45
+ # skip pre-commit exactly as completely as `--no-verify`, and `-nm` is the more
46
+ # likely spelling in practice because it reads as an ordinary message flag.
47
+ #
48
+ # What the old comment got right is the part about `-n` being common, and that
49
+ # concern is answered by SCOPE rather than by giving up: `-n` is `--dry-run` for
50
+ # `push`, `--no-stat` for `merge`, and a line count for head/tail/sort/grep, so
51
+ # the match is confined to the argv of a `git commit` invocation and nothing
52
+ # else. See `git_commit_skips_verification` below.
35
53
  set -euo pipefail
36
54
 
37
55
  input="$(cat)"
@@ -168,6 +186,203 @@ def disables_verification(token):
168
186
  and NO_VERIFY.startswith(token)
169
187
  )
170
188
 
189
+
190
+ # `git commit -n` is the short spelling of --no-verify and skips pre-commit and
191
+ # commit-msg identically. Matching it needs one thing the `--no-verify` scan
192
+ # above does not: SCOPE. `-n` means --dry-run to `git push`, --no-stat to `git
193
+ # merge`, and a line count to head/tail/sort/grep, so a token-anywhere match
194
+ # would refuse ordinary commands all day. Only `git commit` reads it as a hook
195
+ # bypass, so only that invocation's argv is scanned.
196
+ #
197
+ # The tokenizer below is a SECOND pass, deliberately not shared with the one the
198
+ # checks above use. `shlex.split` leaves `|` and `;` glued to their neighbours,
199
+ # which those checks paper over by stripping the punctuation off each token —
200
+ # fine when the question is "does this token look like a bypass anywhere in the
201
+ # line", fatal when the question is "where does this command end", because
202
+ # `git commit -m x && grep -n foo` would then read as one long git invocation
203
+ # and refuse the grep. `punctuation_chars=True` emits the operators as their own
204
+ # tokens, so the invocation's boundary is a token the scan can stop at.
205
+ COMMAND_SEPARATORS = {
206
+ ";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
207
+ }
208
+
209
+ # git's own options that take a SEPARATE value token, which therefore must be
210
+ # skipped when looking for the subcommand: `git -c core.hooksPath=x commit` and
211
+ # `git -C /repo commit` both reach `commit`.
212
+ GIT_GLOBAL_SEPARATE_VALUE = {
213
+ "-c", "-C", "--config-env", "--git-dir", "--work-tree",
214
+ "--namespace", "--exec-path", "--super-prefix",
215
+ }
216
+
217
+ # `git commit` long options whose value can be a separate token. Listed so the
218
+ # VALUE is never mistaken for a flag — `git commit --author "A -n B" -m x`
219
+ # must stay allowed. Only genuinely value-taking options belong here: adding a
220
+ # boolean one by mistake would swallow the token after it, and `git commit
221
+ # --amend -n` would go unnoticed.
222
+ COMMIT_LONG_SEPARATE_VALUE = {
223
+ "--message", "--file", "--author", "--date", "--template", "--cleanup",
224
+ "--reuse-message", "--reedit-message", "--fixup", "--squash",
225
+ "--pathspec-from-file", "--trailer",
226
+ }
227
+
228
+ # Short `git commit` options that REQUIRE a value: `-m`/`-F`/`-c`/`-C`/`-t`.
229
+ # In a cluster the value is whatever follows them in the same token, or the next
230
+ # token when nothing does — which is why `-mn` is the message "n" and not a
231
+ # bypass, and `-nm msg` IS one.
232
+ COMMIT_SHORT_REQUIRED_VALUE = set("mFcCt")
233
+
234
+ # Short options taking an OPTIONAL value, which git only ever reads attached:
235
+ # `-uno` is --untracked-files=no, `-Skeyid` is --gpg-sign=keyid. The cluster
236
+ # ends at them either way, so `-un` is untracked-files "n" rather than a bypass.
237
+ COMMIT_SHORT_OPTIONAL_VALUE = set("uS")
238
+
239
+
240
+ def line_boundaries_as_separators(text):
241
+ """Turn newlines into command separators, keeping continuations intact.
242
+
243
+ A newline ends a command exactly as `;` does, but shlex treats it as plain
244
+ whitespace — so `git commit -m x` on one line and `grep -n foo` on the next
245
+ read as ONE invocation and the grep gets refused. That is the same
246
+ false-positive class the short-form match exists to avoid, arriving through
247
+ the boundary rather than through the token.
248
+
249
+ Backslash-newline is joined first, because there the newline is NOT a
250
+ boundary: `git commit \\` + newline + `-nm x` is one command, and turning
251
+ that newline into a separator would hide a real bypass.
252
+
253
+ Args:
254
+ text: The command line, heredoc payloads already stripped.
255
+
256
+ Returns:
257
+ The same command with line breaks spelled as separators.
258
+ """
259
+ joined = text.replace("\r\n", "\n").replace("\\\n", " ")
260
+ return joined.replace("\n", " ; ")
261
+
262
+
263
+ def shell_tokens(text):
264
+ """Tokenize a command with shell operators kept as their own tokens.
265
+
266
+ Args:
267
+ text: The command line, heredoc payloads already stripped.
268
+
269
+ Returns:
270
+ The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
271
+ """
272
+ lexer = shlex.shlex(
273
+ line_boundaries_as_separators(text), posix=True, punctuation_chars=True
274
+ )
275
+ lexer.whitespace_split = True
276
+ # shlex treats `#` as a comment introducer by default and would silently
277
+ # truncate the rest of the line; `shlex.split` disables it, and so must this.
278
+ lexer.commenters = ""
279
+ return list(lexer)
280
+
281
+
282
+ def cluster_skips_verification(cluster):
283
+ """Read a short-option cluster the way git's own parser reads it.
284
+
285
+ Args:
286
+ cluster: A single token beginning with one `-`, e.g. `-nm` or `-mn`.
287
+
288
+ Returns:
289
+ A pair (bypasses, consumes_next_token). `bypasses` is True when a real
290
+ `-n` option is present; `consumes_next_token` is True when the cluster
291
+ ends in a value-taking option whose value is the following token.
292
+ """
293
+ body = cluster[1:]
294
+ for offset, letter in enumerate(body):
295
+ if letter == "n":
296
+ return (True, False)
297
+ if letter in COMMIT_SHORT_REQUIRED_VALUE:
298
+ # Everything after this letter is the value. It is a separate token
299
+ # only when nothing is attached.
300
+ return (False, offset == len(body) - 1)
301
+ if letter in COMMIT_SHORT_OPTIONAL_VALUE:
302
+ return (False, False)
303
+ return (False, False)
304
+
305
+
306
+ def commit_bypasses_verification(argv):
307
+ """Whether a `git commit` invocation's argv carries a real short `-n`.
308
+
309
+ Args:
310
+ argv: Tokens following the `commit` subcommand, to the end of the line.
311
+
312
+ Returns:
313
+ True if git would read one of them as --no-verify.
314
+ """
315
+ index = 0
316
+ while index < len(argv):
317
+ token = argv[index]
318
+ index += 1
319
+ # `--` ends the options; everything after it is a pathspec, and a file
320
+ # legitimately named `-n` is not a bypass.
321
+ if token in COMMAND_SEPARATORS or token == "--":
322
+ return False
323
+ if token.startswith("--"):
324
+ if token in COMMIT_LONG_SEPARATE_VALUE:
325
+ index += 1
326
+ continue
327
+ # A bare `-` is git's stdin placeholder, not an option cluster.
328
+ if not token.startswith("-") or token == "-":
329
+ continue
330
+ bypasses, consumes_next = cluster_skips_verification(token)
331
+ if bypasses:
332
+ return True
333
+ if consumes_next:
334
+ index += 1
335
+ return False
336
+
337
+
338
+ def subcommand_after_git(tokens, start):
339
+ """Find the subcommand a `git` token introduces, and its argv.
340
+
341
+ Args:
342
+ tokens: The full operator-aware token list.
343
+ start: Index just past the `git` token.
344
+
345
+ Returns:
346
+ A pair (subcommand, argv), or None when the invocation names none.
347
+ """
348
+ index = start
349
+ while index < len(tokens):
350
+ token = tokens[index]
351
+ if token in COMMAND_SEPARATORS:
352
+ return None
353
+ if not token.startswith("-"):
354
+ return (token, tokens[index + 1:])
355
+ index += 2 if token in GIT_GLOBAL_SEPARATE_VALUE else 1
356
+ return None
357
+
358
+
359
+ def git_commit_skips_verification(text):
360
+ """Whether the command runs `git commit` with the short `-n` bypass.
361
+
362
+ Args:
363
+ text: The command line, heredoc payloads already stripped.
364
+
365
+ Returns:
366
+ True if any `git commit` invocation on the line skips verification.
367
+ """
368
+ try:
369
+ scoped_tokens = shell_tokens(text)
370
+ except ValueError:
371
+ return False
372
+ for index, token in enumerate(scoped_tokens):
373
+ # `/usr/bin/git` and a bare `git` are the same program; an env prefix
374
+ # (`HUSKY=1 git commit -n`) simply sits in an earlier token.
375
+ if token != "git" and not token.endswith("/git"):
376
+ continue
377
+ found = subcommand_after_git(scoped_tokens, index + 1)
378
+ if found and found[0] == "commit" and commit_bypasses_verification(found[1]):
379
+ return True
380
+ return False
381
+
382
+
383
+ if git_commit_skips_verification(strip_heredocs(command)):
384
+ sys.exit(1)
385
+
171
386
  for i, token in enumerate(normalized_tokens):
172
387
  if disables_verification(token):
173
388
  sys.exit(1)
@@ -238,8 +453,10 @@ sys.exit(0)
238
453
  PY
239
454
  then
240
455
  cat >&2 <<'EOF'
241
- Blocked: this command bypasses pre-commit/pre-push hooks (--no-verify, HUSKY=0,
242
- or core.hooksPath disabling). Fix the underlying issue (security audit, lint,
456
+ Blocked: this command bypasses pre-commit/pre-push hooks (--no-verify or its
457
+ short form -n, HUSKY=0, or core.hooksPath disabling). `git commit -n` and a
458
+ cluster such as `-nm "msg"` skip pre-commit exactly as completely as the long
459
+ flag. Fix the underlying issue (security audit, lint,
243
460
  typecheck, tests, formatting) instead. If a fix is genuinely impossible, ask the
244
461
  user to make the risk-acceptance decision and add a specific documented ignore;
245
462
  never bypass the hook.