@codyswann/lisa 3.3.0 → 3.4.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.
- package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +31 -0
- package/all/copy-overwrite/scripts/lisa-work-item.mjs +24 -2
- package/dist/codex/scripts/block-no-verify.sh +53 -4
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +33 -12
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +4 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-quality-review/SKILL.md +7 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-review-local/SKILL.md +27 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-track/SKILL.md +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-tracker-source-artifacts/SKILL.md +2 -0
- package/plugins/lisa/hooks/block-no-verify.agy.sh +53 -4
- package/plugins/lisa/hooks/block-no-verify.sh +31 -0
- package/plugins/lisa/rules/eager/design-source-of-truth.md +38 -0
- package/plugins/lisa/rules/eager/tracked-work.md +1 -1
- package/plugins/lisa/rules/reference/design-source-of-truth.md +195 -0
- package/plugins/lisa/rules/reference/tracked-work.md +4 -2
- package/plugins/lisa/scripts/design-source-gate.mjs +530 -0
- package/plugins/lisa/skills/lisa-implement/SKILL.md +4 -2
- package/plugins/lisa/skills/lisa-quality-review/SKILL.md +7 -0
- package/plugins/lisa/skills/lisa-review-local/SKILL.md +27 -0
- package/plugins/lisa/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/plugins/lisa/skills/lisa-track/SKILL.md +1 -1
- package/plugins/lisa/skills/lisa-tracker-source-artifacts/SKILL.md +2 -0
- package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +53 -4
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/scripts/design-source-gate.mjs +530 -0
- package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +4 -2
- package/plugins/lisa-agy/skills/lisa-quality-review/SKILL.md +7 -0
- package/plugins/lisa-agy/skills/lisa-review-local/SKILL.md +27 -0
- package/plugins/lisa-agy/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/plugins/lisa-agy/skills/lisa-track/SKILL.md +1 -1
- package/plugins/lisa-agy/skills/lisa-tracker-source-artifacts/SKILL.md +2 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/block-no-verify.sh +31 -0
- package/plugins/lisa-copilot/rules/eager/design-source-of-truth.md +38 -0
- package/plugins/lisa-copilot/rules/eager/tracked-work.md +1 -1
- package/plugins/lisa-copilot/rules/reference/design-source-of-truth.md +195 -0
- package/plugins/lisa-copilot/rules/reference/tracked-work.md +4 -2
- package/plugins/lisa-copilot/scripts/design-source-gate.mjs +530 -0
- package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +4 -2
- package/plugins/lisa-copilot/skills/lisa-quality-review/SKILL.md +7 -0
- package/plugins/lisa-copilot/skills/lisa-review-local/SKILL.md +27 -0
- package/plugins/lisa-copilot/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/plugins/lisa-copilot/skills/lisa-track/SKILL.md +1 -1
- package/plugins/lisa-copilot/skills/lisa-tracker-source-artifacts/SKILL.md +2 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-no-verify.sh +31 -0
- package/plugins/lisa-cursor/rules/design-source-of-truth-reference.mdc +200 -0
- package/plugins/lisa-cursor/rules/design-source-of-truth.mdc +43 -0
- package/plugins/lisa-cursor/rules/tracked-work-reference.mdc +4 -2
- package/plugins/lisa-cursor/rules/tracked-work.mdc +1 -1
- package/plugins/lisa-cursor/scripts/design-source-gate.mjs +530 -0
- package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +4 -2
- package/plugins/lisa-cursor/skills/lisa-quality-review/SKILL.md +7 -0
- package/plugins/lisa-cursor/skills/lisa-review-local/SKILL.md +27 -0
- package/plugins/lisa-cursor/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/plugins/lisa-cursor/skills/lisa-track/SKILL.md +1 -1
- package/plugins/lisa-cursor/skills/lisa-tracker-source-artifacts/SKILL.md +2 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/block-no-verify.agy.sh +53 -4
- package/plugins/src/base/hooks/block-no-verify.sh +31 -0
- package/plugins/src/base/rules/eager/design-source-of-truth.md +38 -0
- package/plugins/src/base/rules/eager/tracked-work.md +1 -1
- package/plugins/src/base/rules/reference/design-source-of-truth.md +195 -0
- package/plugins/src/base/rules/reference/tracked-work.md +4 -2
- package/plugins/src/base/scripts/design-source-gate.mjs +530 -0
- package/plugins/src/base/skills/lisa-implement/SKILL.md +4 -2
- package/plugins/src/base/skills/lisa-quality-review/SKILL.md +7 -0
- package/plugins/src/base/skills/lisa-review-local/SKILL.md +27 -0
- package/plugins/src/base/skills/lisa-tdd-implementation/SKILL.md +1 -0
- package/plugins/src/base/skills/lisa-track/SKILL.md +1 -1
- package/plugins/src/base/skills/lisa-tracker-source-artifacts/SKILL.md +2 -0
|
@@ -102,16 +102,65 @@ def disables_verification(token):
|
|
|
102
102
|
and NO_VERIFY.startswith(token)
|
|
103
103
|
)
|
|
104
104
|
|
|
105
|
+
# The only relocations that keep a repo's own hooks in play. Anything else —
|
|
106
|
+
# including "" and /dev/null, which are simply the two most obvious members of
|
|
107
|
+
# the blocked set rather than special cases — is refused.
|
|
108
|
+
PERMITTED_HOOKS_PATHS = {".husky", ".githooks"}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def is_permitted_hooks_path(value):
|
|
112
|
+
"""Whether a core.hooksPath value relocates hooks rather than disabling them.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
value: The raw core.hooksPath value as it appeared on the command line.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
True if the path is an established in-repo hooks directory.
|
|
119
|
+
"""
|
|
120
|
+
cleaned = value.strip().strip("'\"")
|
|
121
|
+
if cleaned.startswith("./"):
|
|
122
|
+
cleaned = cleaned[2:]
|
|
123
|
+
return cleaned.rstrip("/") in PERMITTED_HOOKS_PATHS
|
|
124
|
+
|
|
125
|
+
|
|
105
126
|
for i, token in enumerate(normalized_tokens):
|
|
106
127
|
if disables_verification(token):
|
|
107
128
|
sys.exit(1)
|
|
108
129
|
if token == "HUSKY=0" or token.startswith("HUSKY_SKIP_HOOKS="):
|
|
109
130
|
sys.exit(1)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
131
|
+
# Allowlist the destinations, do not denylist the disabling ones: hooks are
|
|
132
|
+
# disabled just as completely by any directory that happens to contain none
|
|
133
|
+
# (`-c core.hooksPath=/tmp/empty`), so the set that DISABLES hooks is
|
|
134
|
+
# unbounded while the set that legitimately relocates them is tiny. Matched
|
|
135
|
+
# case-insensitively because git config names are.
|
|
136
|
+
lowered = token.lower()
|
|
137
|
+
if lowered.startswith("core.hookspath="):
|
|
138
|
+
if not is_permitted_hooks_path(token.split("=", 1)[1]):
|
|
139
|
+
sys.exit(1)
|
|
140
|
+
if lowered == "core.hookspath" and i + 1 < len(normalized_tokens):
|
|
141
|
+
if not is_permitted_hooks_path(normalized_tokens[i + 1]):
|
|
142
|
+
sys.exit(1)
|
|
143
|
+
# `--config-env=core.hooksPath=SOMEVAR` reads the path out of the named env
|
|
144
|
+
# var, so it is not in the command at all and cannot be allowlisted.
|
|
145
|
+
if lowered.startswith("--config-env="):
|
|
146
|
+
spec = token.split("=", 1)[1]
|
|
147
|
+
if spec.split("=", 1)[0].strip().lower() == "core.hookspath":
|
|
148
|
+
sys.exit(1)
|
|
149
|
+
# git also accepts `--config-env <name>=<envvar>` as two tokens. Guarding
|
|
150
|
+
# only the `=` spelling let the trailing `core.hooksPath=.husky` fall
|
|
151
|
+
# through to the allowlist above, which reads `.husky` as a path — but here
|
|
152
|
+
# it names an ENVIRONMENT VARIABLE, which can hold /dev/null. Checked at the
|
|
153
|
+
# `--config-env` token, which the loop reaches first.
|
|
154
|
+
if lowered == "--config-env" and i + 1 < len(normalized_tokens):
|
|
155
|
+
spec = normalized_tokens[i + 1]
|
|
156
|
+
if spec.split("=", 1)[0].strip().strip("'\"").lower() == "core.hookspath":
|
|
113
157
|
sys.exit(1)
|
|
114
|
-
|
|
158
|
+
# `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath
|
|
159
|
+
# GIT_CONFIG_VALUE_0=/dev/null git ...` sets the same command-scope config
|
|
160
|
+
# via env-var-style assignments. The index is arbitrary below
|
|
161
|
+
# GIT_CONFIG_COUNT, so it is matched as `\d+` rather than pinned to 0.
|
|
162
|
+
key_match = re.match(r"git_config_key_\d+=(.*)$", lowered, re.DOTALL)
|
|
163
|
+
if key_match and key_match.group(1).strip().strip("'\"") == "core.hookspath":
|
|
115
164
|
sys.exit(1)
|
|
116
165
|
|
|
117
166
|
sys.exit(0)
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic, fail-closed gate for the `design-source-of-truth` contract.
|
|
4
|
+
*
|
|
5
|
+
* Figma is the design source of truth. Every other Lisa design obligation is
|
|
6
|
+
* conditioned on a design artifact already existing — ticket gate S12 fires only
|
|
7
|
+
* `when artifacts_attached = true` — so UI invented straight into code was
|
|
8
|
+
* governed by nothing at all. This gate governs that case, and only that case:
|
|
9
|
+
* every UI surface a change touches must declare where its design came from,
|
|
10
|
+
* in exactly one of two forms.
|
|
11
|
+
*
|
|
12
|
+
* ```
|
|
13
|
+
* // DESIGN-SOURCE: https://www.figma.com/design/<file>?node-id=<node>
|
|
14
|
+
* // DESIGN-SOURCE: none — not in Figma
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* The first says the surface is backed by a design node (it already existed, or
|
|
18
|
+
* it was synced back). The second says the surface is deliberately not captured
|
|
19
|
+
* at the source; it is the exception, not the default, and the gate reports
|
|
20
|
+
* every one of them so review can challenge it.
|
|
21
|
+
*
|
|
22
|
+
* **The gate fails closed.** Anything it cannot resolve into one of those two
|
|
23
|
+
* declarations is a violation: no annotation, a malformed one, a file that both
|
|
24
|
+
* cites Figma and denies having a source, a changed file it could not read, or
|
|
25
|
+
* a diff it could not compute. A gate that returns PASS when it could not look
|
|
26
|
+
* proves nothing, so it never does.
|
|
27
|
+
*
|
|
28
|
+
* What this module deliberately does NOT decide is *what to build*. Host
|
|
29
|
+
* design-system rules (`figma-design-system`, `design-system`,
|
|
30
|
+
* `use-the-design-library`) own component hierarchy, token vocabulary, and
|
|
31
|
+
* reuse; several are generated from a ratified RFC. This gate stays orthogonal
|
|
32
|
+
* to all of it — it asks only whether the design source is declared.
|
|
33
|
+
*
|
|
34
|
+
* Run it: `node design-source-gate.mjs --base=origin/main [--head=HEAD] [--json]`
|
|
35
|
+
* Exit 0 = PASS, 1 = FAIL (any violation, or anything unresolvable), 2 = usage.
|
|
36
|
+
*/
|
|
37
|
+
import { execFileSync } from "node:child_process";
|
|
38
|
+
import { readFileSync } from "node:fs";
|
|
39
|
+
|
|
40
|
+
/** The one designated marker for UI deliberately not captured in Figma. */
|
|
41
|
+
export const DESIGN_SOURCE_NONE_MARKER = "DESIGN-SOURCE: none — not in Figma";
|
|
42
|
+
|
|
43
|
+
/** The annotation keyword, in whatever comment syntax the file uses. */
|
|
44
|
+
const ANNOTATION = /DESIGN-SOURCE:[ \t]*(?<value>[^\n]*)/gu;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The none-form, plus an optional trailing reason. The reason is optional so
|
|
48
|
+
* the marker's documented spelling stays exactly what the rule prints, but a
|
|
49
|
+
* reasonless marker is reported for sync-back when Figma access is proven.
|
|
50
|
+
*/
|
|
51
|
+
const NONE_FORM =
|
|
52
|
+
/^none[ \t]*(?:—|--|–)[ \t]*not in Figma(?:[ \t]*(?:—|--|–)[ \t]*(?<reason>.+?))?[ \t]*(?:-->|\*\/|\*|#)?[ \t]*$/u;
|
|
53
|
+
|
|
54
|
+
/** Only a Figma URL seals a surface. Any other link is malformed, not proof. */
|
|
55
|
+
const FIGMA_URL = /^https:\/\/(?:[\w-]+\.)*figma\.com\/[^\s)]+$/u;
|
|
56
|
+
|
|
57
|
+
/** Extensions that are a UI surface wherever they live. */
|
|
58
|
+
const UI_EXTENSIONS = [".tsx", ".jsx", ".vue", ".svelte"];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Markup and style extensions that are a UI surface only inside a directory
|
|
62
|
+
* that renders. A `.ts` barrel or a pure-logic module under `components/`
|
|
63
|
+
* renders nothing, so extension-plus-directory keeps the gate off code that has
|
|
64
|
+
* no design to declare.
|
|
65
|
+
*/
|
|
66
|
+
const UI_MARKUP_EXTENSIONS = [
|
|
67
|
+
".css",
|
|
68
|
+
".scss",
|
|
69
|
+
".sass",
|
|
70
|
+
".less",
|
|
71
|
+
".styl",
|
|
72
|
+
".html",
|
|
73
|
+
".erb",
|
|
74
|
+
".haml",
|
|
75
|
+
".slim",
|
|
76
|
+
".swift",
|
|
77
|
+
".kt",
|
|
78
|
+
".dart",
|
|
79
|
+
".xml",
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** Directory names that mark a rendering surface. */
|
|
83
|
+
const UI_DIRECTORIES = [
|
|
84
|
+
"components",
|
|
85
|
+
"screens",
|
|
86
|
+
"views",
|
|
87
|
+
"pages",
|
|
88
|
+
"ui",
|
|
89
|
+
"widgets",
|
|
90
|
+
"layouts",
|
|
91
|
+
"templates",
|
|
92
|
+
"atoms",
|
|
93
|
+
"molecules",
|
|
94
|
+
"organisms",
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/** Never a UI surface: generated output, vendored code, and test scaffolding. */
|
|
98
|
+
const DEFAULT_EXCLUDES = [
|
|
99
|
+
"**/node_modules/**",
|
|
100
|
+
"dist/**",
|
|
101
|
+
"build/**",
|
|
102
|
+
"coverage/**",
|
|
103
|
+
".next/**",
|
|
104
|
+
"**/*.d.ts",
|
|
105
|
+
"**/*.test.*",
|
|
106
|
+
"**/*.spec.*",
|
|
107
|
+
"**/*.stories.*",
|
|
108
|
+
"**/__tests__/**",
|
|
109
|
+
"**/__snapshots__/**",
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
/** Change types that carry no head content to annotate. */
|
|
113
|
+
const REMOVED_CHANGE_TYPES = new Set(["deleted", "D", "removed"]);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Translate a `**`/`*` glob into an anchored regular expression. Deliberately
|
|
117
|
+
* tiny: the gate must run from a plugin directory with no dependency install,
|
|
118
|
+
* so pulling in a glob library is not an option.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} pattern Glob pattern, `/`-separated.
|
|
121
|
+
* @returns {RegExp} Anchored matcher for a repo-relative path.
|
|
122
|
+
*/
|
|
123
|
+
function globToRegExp(pattern) {
|
|
124
|
+
// Scanned in one pass rather than chained replacements: a second pass would
|
|
125
|
+
// re-expand the `*` inside a `.*` it had just emitted, which silently turns
|
|
126
|
+
// `**/node_modules/**` into a pattern that matches nothing.
|
|
127
|
+
let body = "";
|
|
128
|
+
let index = 0;
|
|
129
|
+
|
|
130
|
+
while (index < pattern.length) {
|
|
131
|
+
const char = pattern[index];
|
|
132
|
+
if (char === "*" && pattern[index + 1] === "*") {
|
|
133
|
+
const slashed = pattern[index + 2] === "/";
|
|
134
|
+
body += slashed ? "(?:[^/]*/)*" : ".*";
|
|
135
|
+
index += slashed ? 3 : 2;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (char === "*") {
|
|
139
|
+
body += "[^/]*";
|
|
140
|
+
index += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (char === "?") {
|
|
144
|
+
body += "[^/]";
|
|
145
|
+
index += 1;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
body += ".+^${}()|[]\\".includes(char) ? `\\${char}` : char;
|
|
149
|
+
index += 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return new RegExp(`^${body}$`, "u");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {string} relPath Repo-relative path.
|
|
157
|
+
* @param {readonly string[]} patterns Glob patterns to test.
|
|
158
|
+
* @returns {boolean} True when any pattern matches.
|
|
159
|
+
*/
|
|
160
|
+
function matchesAny(relPath, patterns) {
|
|
161
|
+
return patterns.some(pattern => globToRegExp(pattern).test(relPath));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param {string} relPath Repo-relative path.
|
|
166
|
+
* @returns {string} Lowercased extension including the dot, or "".
|
|
167
|
+
*/
|
|
168
|
+
function extensionOf(relPath) {
|
|
169
|
+
const base = relPath.slice(relPath.lastIndexOf("/") + 1);
|
|
170
|
+
const dot = base.lastIndexOf(".");
|
|
171
|
+
return dot <= 0 ? "" : base.slice(dot).toLowerCase();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Decide whether a path is a UI surface — something a user can observe.
|
|
176
|
+
*
|
|
177
|
+
* Membership is surface, not repo name or file extension alone: an extension
|
|
178
|
+
* that always renders counts anywhere, while markup and styles count inside a
|
|
179
|
+
* rendering directory. Projects narrow or widen this through
|
|
180
|
+
* `designSource.include` / `designSource.exclude` in `.lisa.config.json`.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} relPath Repo-relative path of the changed file.
|
|
183
|
+
* @param {{ include?: readonly string[], exclude?: readonly string[] } | undefined} [config] Project overrides.
|
|
184
|
+
* @returns {boolean} True when the path is a UI surface.
|
|
185
|
+
*/
|
|
186
|
+
export function isUiSurface(relPath, config) {
|
|
187
|
+
if (typeof relPath !== "string" || relPath.length === 0) return false;
|
|
188
|
+
|
|
189
|
+
const normalized = relPath.replaceAll("\\", "/");
|
|
190
|
+
const exclude = [...DEFAULT_EXCLUDES, ...(config?.exclude ?? [])];
|
|
191
|
+
if (matchesAny(normalized, exclude)) return false;
|
|
192
|
+
|
|
193
|
+
const include = config?.include ?? [];
|
|
194
|
+
if (include.length > 0 && matchesAny(normalized, include)) return true;
|
|
195
|
+
|
|
196
|
+
const extension = extensionOf(normalized);
|
|
197
|
+
if (UI_EXTENSIONS.includes(extension)) return true;
|
|
198
|
+
|
|
199
|
+
if (!UI_MARKUP_EXTENSIONS.includes(extension)) return false;
|
|
200
|
+
const segments = normalized.split("/").slice(0, -1);
|
|
201
|
+
return segments.some(segment =>
|
|
202
|
+
UI_DIRECTORIES.includes(segment.toLowerCase())
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Strip a trailing comment terminator so a marker written in block or markup
|
|
208
|
+
* comment syntax yields the same reason as a line comment would.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} value Raw annotation value.
|
|
211
|
+
* @returns {string} Value with any trailing comment close removed.
|
|
212
|
+
*/
|
|
213
|
+
function stripCommentClose(value) {
|
|
214
|
+
return value.replace(/[ \t]*(?:-->|\*\/|\*\}|\}\)?|#>)[ \t]*$/u, "").trim();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Read every design-source annotation out of a file body.
|
|
219
|
+
*
|
|
220
|
+
* @param {string} content File body at the head of the change.
|
|
221
|
+
* @returns {{ figma: string[], none: { reason: string | null }[], malformed: string[] }} Parsed annotations.
|
|
222
|
+
*/
|
|
223
|
+
function parseAnnotations(content) {
|
|
224
|
+
const figma = [];
|
|
225
|
+
const none = [];
|
|
226
|
+
const malformed = [];
|
|
227
|
+
|
|
228
|
+
for (const match of content.matchAll(ANNOTATION)) {
|
|
229
|
+
const value = stripCommentClose(match.groups?.value ?? "");
|
|
230
|
+
const noneMatch = NONE_FORM.exec(value);
|
|
231
|
+
if (noneMatch) {
|
|
232
|
+
none.push({ reason: noneMatch.groups?.reason?.trim() || null });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (FIGMA_URL.test(value)) {
|
|
236
|
+
figma.push(value);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
malformed.push(value);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { figma, none, malformed };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Classify one changed file against the contract.
|
|
247
|
+
*
|
|
248
|
+
* Statuses: `figma-source` (sealed), `marked-exception` (declared exception),
|
|
249
|
+
* `not-applicable` (not a UI surface, or a deletion), and the four failing
|
|
250
|
+
* states `undeclared`, `malformed`, `conflicting`, and `unreadable`.
|
|
251
|
+
*
|
|
252
|
+
* @param {{ path: string, changeType?: string, content?: string | null }} file Changed-file record.
|
|
253
|
+
* @param {{ include?: readonly string[], exclude?: readonly string[] } | undefined} [config] Project overrides.
|
|
254
|
+
* @returns {{ path: string, uiSurface: boolean, status: string, evidence: string | null, reason: string | null }} Classification.
|
|
255
|
+
*/
|
|
256
|
+
export function classifyDesignSource(file, config) {
|
|
257
|
+
const relPath = file?.path ?? "";
|
|
258
|
+
const notApplicable = {
|
|
259
|
+
path: relPath,
|
|
260
|
+
uiSurface: false,
|
|
261
|
+
status: "not-applicable",
|
|
262
|
+
evidence: null,
|
|
263
|
+
reason: null,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
if (REMOVED_CHANGE_TYPES.has(file?.changeType ?? "")) return notApplicable;
|
|
267
|
+
if (!isUiSurface(relPath, config)) return notApplicable;
|
|
268
|
+
|
|
269
|
+
const base = { path: relPath, uiSurface: true, evidence: null, reason: null };
|
|
270
|
+
|
|
271
|
+
if (typeof file?.content !== "string") {
|
|
272
|
+
return { ...base, status: "unreadable" };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const { figma, none, malformed } = parseAnnotations(file.content);
|
|
276
|
+
|
|
277
|
+
if (figma.length > 0 && none.length > 0) {
|
|
278
|
+
return { ...base, status: "conflicting", evidence: figma[0] };
|
|
279
|
+
}
|
|
280
|
+
if (malformed.length > 0) {
|
|
281
|
+
return { ...base, status: "malformed", evidence: malformed[0] };
|
|
282
|
+
}
|
|
283
|
+
if (figma.length > 0) {
|
|
284
|
+
return { ...base, status: "figma-source", evidence: figma[0] };
|
|
285
|
+
}
|
|
286
|
+
if (none.length > 0) {
|
|
287
|
+
return {
|
|
288
|
+
...base,
|
|
289
|
+
status: "marked-exception",
|
|
290
|
+
evidence: DESIGN_SOURCE_NONE_MARKER,
|
|
291
|
+
reason: none[0]?.reason ?? null,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return { ...base, status: "undeclared" };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Per-file statuses that fail the gate. */
|
|
298
|
+
const VIOLATION_STATUSES = new Set([
|
|
299
|
+
"undeclared",
|
|
300
|
+
"malformed",
|
|
301
|
+
"conflicting",
|
|
302
|
+
"unreadable",
|
|
303
|
+
]);
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Evaluate a whole change.
|
|
307
|
+
*
|
|
308
|
+
* Only the files this change touched are judged — a repo full of unannotated
|
|
309
|
+
* legacy UI is burndown, not this work item's blocker. But the gate refuses to
|
|
310
|
+
* pass on ignorance: an unresolved file list or a reported diff error is a FAIL
|
|
311
|
+
* with a named reason, never a quiet PASS.
|
|
312
|
+
*
|
|
313
|
+
* @param {{
|
|
314
|
+
* files?: readonly { path: string, changeType?: string, content?: string | null }[] | null,
|
|
315
|
+
* diffError?: string | null,
|
|
316
|
+
* figmaAccess?: boolean,
|
|
317
|
+
* config?: { include?: readonly string[], exclude?: readonly string[] }
|
|
318
|
+
* }} input Change under evaluation.
|
|
319
|
+
* @returns {{
|
|
320
|
+
* verdict: "PASS" | "FAIL",
|
|
321
|
+
* reasons: string[],
|
|
322
|
+
* violations: object[],
|
|
323
|
+
* exceptions: object[],
|
|
324
|
+
* syncBackPreferred: object[],
|
|
325
|
+
* sealed: object[],
|
|
326
|
+
* notApplicable: object[],
|
|
327
|
+
* summary: { judged: number, sealed: number, exceptions: number, violations: number }
|
|
328
|
+
* }} The gate result.
|
|
329
|
+
*/
|
|
330
|
+
export function evaluateDesignSource(input = {}) {
|
|
331
|
+
const reasons = [];
|
|
332
|
+
const files = Array.isArray(input.files) ? input.files : null;
|
|
333
|
+
|
|
334
|
+
if (files === null) reasons.push("changed-files-unresolved");
|
|
335
|
+
if (
|
|
336
|
+
typeof input.diffError === "string" &&
|
|
337
|
+
input.diffError.trim().length > 0
|
|
338
|
+
) {
|
|
339
|
+
reasons.push("diff-unresolved");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const classified = (files ?? []).map(file =>
|
|
343
|
+
classifyDesignSource(file, input.config)
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const sealed = classified.filter(entry => entry.status === "figma-source");
|
|
347
|
+
const exceptions = classified.filter(
|
|
348
|
+
entry => entry.status === "marked-exception"
|
|
349
|
+
);
|
|
350
|
+
const violations = classified.filter(entry =>
|
|
351
|
+
VIOLATION_STATUSES.has(entry.status)
|
|
352
|
+
);
|
|
353
|
+
const notApplicable = classified.filter(
|
|
354
|
+
entry => entry.status === "not-applicable"
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// Sync-back is the preference, not a second blocking gate: when Figma access
|
|
358
|
+
// is proven, an exception that records no reason is surfaced so review can
|
|
359
|
+
// ask why it was not captured at the source instead.
|
|
360
|
+
const syncBackPreferred =
|
|
361
|
+
input.figmaAccess === true
|
|
362
|
+
? exceptions.filter(entry => entry.reason === null)
|
|
363
|
+
: [];
|
|
364
|
+
|
|
365
|
+
for (const entry of violations) reasons.push(`${entry.status}:${entry.path}`);
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
verdict: reasons.length === 0 ? "PASS" : "FAIL",
|
|
369
|
+
reasons,
|
|
370
|
+
violations,
|
|
371
|
+
exceptions,
|
|
372
|
+
syncBackPreferred,
|
|
373
|
+
sealed,
|
|
374
|
+
notApplicable,
|
|
375
|
+
summary: {
|
|
376
|
+
judged: sealed.length + exceptions.length + violations.length,
|
|
377
|
+
sealed: sealed.length,
|
|
378
|
+
exceptions: exceptions.length,
|
|
379
|
+
violations: violations.length,
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Collect the changed files of a git range, reading each one at the head.
|
|
386
|
+
*
|
|
387
|
+
* @param {string} base Base revision.
|
|
388
|
+
* @param {string} head Head revision.
|
|
389
|
+
* @returns {{ files: object[] | null, diffError: string | null }} Changed files, or the failure.
|
|
390
|
+
*/
|
|
391
|
+
export function collectChangedFiles(base, head) {
|
|
392
|
+
let output = "";
|
|
393
|
+
try {
|
|
394
|
+
output = execFileSync(
|
|
395
|
+
"git",
|
|
396
|
+
["diff", "--name-status", "--no-renames", `${base}...${head}`],
|
|
397
|
+
{ encoding: "utf8" }
|
|
398
|
+
);
|
|
399
|
+
} catch (error) {
|
|
400
|
+
return { files: null, diffError: String(error?.message ?? error) };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const files = output
|
|
404
|
+
.split("\n")
|
|
405
|
+
.filter(line => line.trim().length > 0)
|
|
406
|
+
.map(line => {
|
|
407
|
+
const [rawStatus, relPath] = line.split("\t");
|
|
408
|
+
const changeType = rawStatus?.startsWith("D") ? "deleted" : "modified";
|
|
409
|
+
if (changeType === "deleted") {
|
|
410
|
+
return { path: relPath ?? "", changeType, content: null };
|
|
411
|
+
}
|
|
412
|
+
let content = null;
|
|
413
|
+
try {
|
|
414
|
+
content = readFileSync(relPath ?? "", "utf8");
|
|
415
|
+
} catch {
|
|
416
|
+
content = null;
|
|
417
|
+
}
|
|
418
|
+
return { path: relPath ?? "", changeType, content };
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
return { files, diffError: null };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Read the optional `designSource` block from `.lisa.config.json`.
|
|
426
|
+
*
|
|
427
|
+
* @returns {{ include?: string[], exclude?: string[] }} Project overrides, or {}.
|
|
428
|
+
*/
|
|
429
|
+
function readProjectConfig() {
|
|
430
|
+
try {
|
|
431
|
+
const raw = JSON.parse(readFileSync(".lisa.config.json", "utf8"));
|
|
432
|
+
return raw?.designSource ?? {};
|
|
433
|
+
} catch {
|
|
434
|
+
return {};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Render the operator-readable report. Written for the non-technical operator
|
|
440
|
+
* standing at the gate, so it names the file and the exact next action.
|
|
441
|
+
*
|
|
442
|
+
* @param {ReturnType<typeof evaluateDesignSource>} result Gate result.
|
|
443
|
+
* @returns {string} Report text.
|
|
444
|
+
*/
|
|
445
|
+
export function renderReport(result) {
|
|
446
|
+
const lines = [`design-source gate: ${result.verdict}`];
|
|
447
|
+
|
|
448
|
+
if (result.verdict === "PASS") {
|
|
449
|
+
lines.push(
|
|
450
|
+
` ${result.summary.sealed} surface(s) backed by Figma, ${result.summary.exceptions} explicitly marked as not in Figma.`
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
for (const entry of result.violations) {
|
|
455
|
+
lines.push(` ✗ ${entry.path} — ${entry.status}`);
|
|
456
|
+
}
|
|
457
|
+
if (result.violations.length > 0) {
|
|
458
|
+
lines.push(
|
|
459
|
+
"",
|
|
460
|
+
" Each file above changes something a user can see but does not say where its design came from.",
|
|
461
|
+
" Fix it one of two ways, sync-back first:",
|
|
462
|
+
" 1. Reflect the surface in Figma, then annotate it: `DESIGN-SOURCE: <figma-url>`",
|
|
463
|
+
` 2. If it genuinely is not captured at the source, annotate: \`${DESIGN_SOURCE_NONE_MARKER}\``
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
if (result.reasons.includes("changed-files-unresolved")) {
|
|
467
|
+
lines.push(" ✗ the set of changed files could not be resolved");
|
|
468
|
+
}
|
|
469
|
+
if (result.reasons.includes("diff-unresolved")) {
|
|
470
|
+
lines.push(
|
|
471
|
+
" ✗ the diff could not be computed — the gate cannot pass on a change it could not read"
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
for (const entry of result.syncBackPreferred) {
|
|
475
|
+
lines.push(
|
|
476
|
+
` ! ${entry.path} — marked as not in Figma with no reason recorded, and Figma access is available. Prefer syncing it back.`
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return lines.join("\n");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* CLI entrypoint.
|
|
485
|
+
*
|
|
486
|
+
* @param {readonly string[]} argv Arguments after the script name.
|
|
487
|
+
* @returns {number} Process exit code.
|
|
488
|
+
*/
|
|
489
|
+
export function runCli(argv) {
|
|
490
|
+
const args = new Map(
|
|
491
|
+
argv
|
|
492
|
+
.filter(arg => arg.startsWith("--"))
|
|
493
|
+
.map(arg => {
|
|
494
|
+
const eq = arg.indexOf("=");
|
|
495
|
+
return eq === -1
|
|
496
|
+
? [arg.slice(2), "true"]
|
|
497
|
+
: [arg.slice(2, eq), arg.slice(eq + 1)];
|
|
498
|
+
})
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
const base = args.get("base");
|
|
502
|
+
if (!base) {
|
|
503
|
+
process.stderr.write(
|
|
504
|
+
"usage: design-source-gate.mjs --base=<ref> [--head=<ref>] [--figma-access] [--json]\n"
|
|
505
|
+
);
|
|
506
|
+
return 2;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const { files, diffError } = collectChangedFiles(
|
|
510
|
+
base,
|
|
511
|
+
args.get("head") ?? "HEAD"
|
|
512
|
+
);
|
|
513
|
+
const result = evaluateDesignSource({
|
|
514
|
+
files,
|
|
515
|
+
diffError,
|
|
516
|
+
figmaAccess: args.get("figma-access") === "true",
|
|
517
|
+
config: readProjectConfig(),
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
process.stdout.write(
|
|
521
|
+
args.get("json") === "true"
|
|
522
|
+
? `${JSON.stringify(result, null, 2)}\n`
|
|
523
|
+
: `${renderReport(result)}\n`
|
|
524
|
+
);
|
|
525
|
+
return result.verdict === "PASS" ? 0 : 1;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (process.argv[1]?.endsWith("design-source-gate.mjs")) {
|
|
529
|
+
process.exit(runCli(process.argv.slice(2)));
|
|
530
|
+
}
|
|
@@ -64,7 +64,7 @@ The input-resolver invokes `lisa-track $ARGUMENTS` and owns its complete resolve
|
|
|
64
64
|
- **Bind before durable work:** only after the verified claim, run:
|
|
65
65
|
|
|
66
66
|
```bash
|
|
67
|
-
node scripts/lisa-work-item.mjs
|
|
67
|
+
node scripts/lisa-work-item.mjs link <canonical-ref>
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
Require a successful readback of that worktree-local binding. On detached HEAD, `branch: null` is the expected pending binding; after branch creation the mandatory `attach-branch` step below must replace it before any commit. Tracker or binding failure stops the flow; never continue untracked.
|
|
@@ -155,7 +155,9 @@ IF it is a Fix (bug), execute the Reproduce sub-flow FIRST:
|
|
|
155
155
|
|
|
156
156
|
For any Fix flow, and for any Build flow that changes user-visible behavior, regression coverage is a required deliverable at the highest practical observation level for the reported surface. If the project has a browser, device, or end-to-end harness for that platform (for example Playwright, Maestro, Detox, Cypress, or an equivalent runtime), the task plan and definition of done MUST include a deterministic regression spec against the reported surface, using mocked or seeded data where needed. This is alongside unit or integration coverage, not a substitute for it. For **frontend work** the deliverable is defined by the `bdd-e2e-coverage` rule and has two halves, both landing in this PR: the Gherkin scenario (stable ID, required platforms) added or updated in the project's behavior contract, and aligned automation in the project's configured runner for **every** platform that scenario requires — no runner substitutes for another, because they guard different platforms of the same behavior. The coverage gate must pass and the matrix and burndown be regenerated before the item is done. Cite the rule; do not restate its scenario, waiver, or bootstrap mechanics here.
|
|
157
157
|
|
|
158
|
-
For work that adds or changes **
|
|
158
|
+
For work that adds or changes a **UI surface**, the `design-source-of-truth` rule adds a second non-demotable deliverable landing in this same PR: every UI surface the change touches declares where its design came from. Figma is the source of truth, so **prefer sync-back** — if the surface is not in Figma yet and the tool-access preflight proved Figma access, reflect it there and cite the node with `DESIGN-SOURCE: <figma-url>`. Only when the surface genuinely does not belong in the design source (debug affordance, dev-only playground, internal tooling) does it carry the exception marker `DESIGN-SOURCE: none — not in Figma`, ideally with a trailing reason. `scripts/design-source-gate.mjs` decides this deterministically and fails closed on anything it cannot resolve; a FAIL blocks the item exactly as a failing coverage gate does. Host design-system rules (`figma-design-system`, `design-system`, `use-the-design-library`, or the project's equivalent) stay authoritative about *what* to build — this obligation is only about declaring the source. Cite the rule; do not restate its marker grammar, host-precedence, or bootstrap mechanics here.
|
|
159
|
+
|
|
160
|
+
For work that adds or changes **persistent state**, the `reset-seed-coverage` rule adds a third non-demotable deliverable landing in this same PR: every entity the item introduces or changes is classified in the project's state contract (`fixture-owned` / `preserve` / `derived-rebuild` / `forbidden`) with a reason and an owner, anything `fixture-owned` declares its ownership predicate and is actually swept, and the state-classification check passes. Writing a flow that creates a record and deletes it only on its happy path does NOT satisfy this — that is the leak, not the coverage. Cite the rule; do not restate its policy, waiver, or bootstrap mechanics here.
|
|
159
161
|
|
|
160
162
|
The team lead may not waive, defer, demote, or phrase this regression spec as "optional", "if cheap", "nice to have", or equivalent. The only permitted exits are:
|
|
161
163
|
|
|
@@ -16,6 +16,13 @@ For each changed file, evaluate:
|
|
|
16
16
|
3. **Test coverage** -- Tests present? Testing behavior, not implementation details? Edge cases covered?
|
|
17
17
|
4. **Documentation** -- JSDoc on new functions explaining "why"? Preambles on new files?
|
|
18
18
|
5. **Code clarity** -- Readable variable names? Unnecessary complexity? Could a new team member understand this?
|
|
19
|
+
6. **Design source** -- For UI surfaces, does each changed file say where its design came from? Run the deterministic gate rather than judging by eye:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
node "${CLAUDE_PLUGIN_ROOT:-.}/scripts/design-source-gate.mjs" --base=main --head=HEAD
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Exit 1 is a **Critical** finding under the `design-source-of-truth` rule -- the change is blocked until every UI surface either cites a Figma node (`DESIGN-SOURCE: <figma-url>`, the preferred fix -- sync it back) or carries the exception marker `DESIGN-SOURCE: none — not in Figma`. The gate fails closed: an unreadable file or an uncomputable diff is a FAIL, not a pass. Host design-system rules (`figma-design-system`, `design-system`, `use-the-design-library`, or the project's equivalent) stay authoritative about what to build; this checks only that the source is declared. If the gate script is absent, say so in the review rather than skipping silently.
|
|
19
26
|
|
|
20
27
|
## Output Format
|
|
21
28
|
|
|
@@ -14,6 +14,33 @@ maintainability feedback are non-blocking unless a repository rule or work item
|
|
|
14
14
|
explicitly makes them release criteria. A blocking finding without a concrete
|
|
15
15
|
failure scenario is malformed and must be filtered out.
|
|
16
16
|
|
|
17
|
+
One deterministic gate runs ahead of the judgement-based review and is exempt
|
|
18
|
+
from the confidence filtering below, because it is decided by a script rather
|
|
19
|
+
than by an agent's opinion.
|
|
20
|
+
|
|
21
|
+
**Design-source gate (`design-source-of-truth` rule).** Run it against the
|
|
22
|
+
branch diff before step 1:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
node "${CLAUDE_PLUGIN_ROOT:-.}/scripts/design-source-gate.mjs" --base=main --head=HEAD
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Exit 0 = PASS, exit 1 = FAIL. A FAIL is a **blocking** review finding and is
|
|
29
|
+
reported verbatim at the top of the review — it is never scored, never filtered
|
|
30
|
+
by confidence, and never demoted to a nitpick. It qualifies under
|
|
31
|
+
`convergent-review` because it names a concrete failure scenario: the design
|
|
32
|
+
source silently diverges from the shipped product and nobody can tell which one
|
|
33
|
+
is authoritative. The gate fails closed, so an unresolvable diff or an unreadable
|
|
34
|
+
file is a FAIL too. Each violation is fixed one of two ways, sync-back first:
|
|
35
|
+
reflect the surface in Figma and cite the node with `DESIGN-SOURCE: <figma-url>`,
|
|
36
|
+
or — only when the surface genuinely does not belong in the design source — mark
|
|
37
|
+
it `DESIGN-SOURCE: none — not in Figma`. Host design-system rules
|
|
38
|
+
(`figma-design-system`, `design-system`, `use-the-design-library`, or the
|
|
39
|
+
project's equivalent) remain authoritative about what to build; this gate only
|
|
40
|
+
asks whether the source is declared. If the gate script is not present (the
|
|
41
|
+
project predates the plugin version that ships it), say so explicitly in the
|
|
42
|
+
review — a silent skip is not one of the exits.
|
|
43
|
+
|
|
17
44
|
To do this, follow these steps precisely:
|
|
18
45
|
|
|
19
46
|
1. Use a Haiku agent to check the current git state:
|