@elalfner/relevo 0.0.0-stage → 0.3.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 (139) hide show
  1. package/.claude-plugin/plugin.json +19 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +51 -0
  4. package/README.es.md +90 -0
  5. package/README.md +89 -2
  6. package/agents/dev.md +101 -0
  7. package/agents/qa.md +112 -0
  8. package/package.json +42 -4
  9. package/scripts/add-corrections.mjs +21 -0
  10. package/scripts/app-id.mjs +19 -0
  11. package/scripts/archive-state.mjs +31 -0
  12. package/scripts/branch-state.mjs +31 -0
  13. package/scripts/branch-work.mjs +25 -0
  14. package/scripts/can-edit-issue.mjs +45 -0
  15. package/scripts/capture.mjs +22 -0
  16. package/scripts/check-archive.mjs +19 -0
  17. package/scripts/check-issue.mjs +41 -0
  18. package/scripts/checklist.mjs +29 -0
  19. package/scripts/cli.mjs +28 -0
  20. package/scripts/commit-change.mjs +50 -0
  21. package/scripts/conflict-scope.mjs +19 -0
  22. package/scripts/design-fetch.mjs +103 -0
  23. package/scripts/device.mjs +12 -0
  24. package/scripts/digest.mjs +50 -0
  25. package/scripts/doctor.mjs +47 -0
  26. package/scripts/fix-guard.mjs +38 -0
  27. package/scripts/fix-stash.mjs +57 -0
  28. package/scripts/fix-state.mjs +28 -0
  29. package/scripts/fix-tasks.mjs +22 -0
  30. package/scripts/go-local.mjs +49 -0
  31. package/scripts/go-plan.mjs +82 -0
  32. package/scripts/init.mjs +109 -0
  33. package/scripts/intent-to-add.mjs +20 -0
  34. package/scripts/issue-coverage.mjs +30 -0
  35. package/scripts/issue-hash.mjs +44 -0
  36. package/scripts/issue-sections.mjs +42 -0
  37. package/scripts/label-change.mjs +35 -0
  38. package/scripts/lib/appid.mjs +12 -0
  39. package/scripts/lib/archive-check.mjs +29 -0
  40. package/scripts/lib/archive-state.mjs +15 -0
  41. package/scripts/lib/branch-work.mjs +13 -0
  42. package/scripts/lib/branch.mjs +43 -0
  43. package/scripts/lib/can-edit-issue.mjs +34 -0
  44. package/scripts/lib/checklist.mjs +68 -0
  45. package/scripts/lib/cli.mjs +82 -0
  46. package/scripts/lib/commit-change.mjs +57 -0
  47. package/scripts/lib/config.mjs +296 -0
  48. package/scripts/lib/design-fetch.mjs +156 -0
  49. package/scripts/lib/device.mjs +67 -0
  50. package/scripts/lib/doctor.mjs +614 -0
  51. package/scripts/lib/execution-mode.mjs +24 -0
  52. package/scripts/lib/fix-guard.mjs +23 -0
  53. package/scripts/lib/fix-stash.mjs +17 -0
  54. package/scripts/lib/fix.mjs +31 -0
  55. package/scripts/lib/github.mjs +161 -0
  56. package/scripts/lib/go-local.mjs +102 -0
  57. package/scripts/lib/init.mjs +324 -0
  58. package/scripts/lib/intent-to-add.mjs +32 -0
  59. package/scripts/lib/issue-check.mjs +63 -0
  60. package/scripts/lib/issue-coverage.mjs +47 -0
  61. package/scripts/lib/issue-hash.mjs +25 -0
  62. package/scripts/lib/issue-parse.mjs +194 -0
  63. package/scripts/lib/issue-sections.mjs +43 -0
  64. package/scripts/lib/labels.mjs +65 -0
  65. package/scripts/lib/language.mjs +9 -0
  66. package/scripts/lib/logaudit.mjs +123 -0
  67. package/scripts/lib/messages.mjs +328 -0
  68. package/scripts/lib/next-step.mjs +144 -0
  69. package/scripts/lib/openspec-commands.mjs +44 -0
  70. package/scripts/lib/openspec-status.mjs +16 -0
  71. package/scripts/lib/plan.mjs +256 -0
  72. package/scripts/lib/platform-of.mjs +32 -0
  73. package/scripts/lib/platform-scope.mjs +32 -0
  74. package/scripts/lib/platforms.mjs +90 -0
  75. package/scripts/lib/preflight.mjs +71 -0
  76. package/scripts/lib/protected-paths.mjs +102 -0
  77. package/scripts/lib/requeue.mjs +23 -0
  78. package/scripts/lib/review-adapters.mjs +37 -0
  79. package/scripts/lib/review-brief.mjs +43 -0
  80. package/scripts/lib/review-events.mjs +56 -0
  81. package/scripts/lib/review-state.mjs +157 -0
  82. package/scripts/lib/reviewers.mjs +39 -0
  83. package/scripts/lib/role-skills.mjs +112 -0
  84. package/scripts/lib/run-flags.mjs +16 -0
  85. package/scripts/lib/run-usage.mjs +65 -0
  86. package/scripts/lib/scan.mjs +73 -0
  87. package/scripts/lib/ship.mjs +161 -0
  88. package/scripts/lib/sla.mjs +90 -0
  89. package/scripts/lib/stack-profile.mjs +169 -0
  90. package/scripts/lib/staging.mjs +21 -0
  91. package/scripts/lib/status-data.mjs +109 -0
  92. package/scripts/lib/status.mjs +199 -0
  93. package/scripts/lib/tasks-file.mjs +81 -0
  94. package/scripts/lib/web-review.mjs +98 -0
  95. package/scripts/lib/yaml-lite.mjs +157 -0
  96. package/scripts/needs-reviewer.mjs +41 -0
  97. package/scripts/next-step.mjs +39 -0
  98. package/scripts/pick-reviewer.mjs +42 -0
  99. package/scripts/platform-of.mjs +49 -0
  100. package/scripts/preflight.mjs +32 -0
  101. package/scripts/render-message.mjs +35 -0
  102. package/scripts/requeue.mjs +53 -0
  103. package/scripts/review-adapter.mjs +32 -0
  104. package/scripts/review-brief.mjs +29 -0
  105. package/scripts/review-event.mjs +70 -0
  106. package/scripts/review-sla.mjs +41 -0
  107. package/scripts/review-state.mjs +171 -0
  108. package/scripts/review-who.mjs +21 -0
  109. package/scripts/role-skills.mjs +53 -0
  110. package/scripts/run-usage.mjs +22 -0
  111. package/scripts/scan-diff.mjs +16 -0
  112. package/scripts/ship-check.mjs +54 -0
  113. package/scripts/spec-ready.mjs +40 -0
  114. package/scripts/stack-profile.mjs +67 -0
  115. package/scripts/stage.mjs +14 -0
  116. package/scripts/status.mjs +47 -0
  117. package/scripts/usage-limit.mjs +17 -0
  118. package/scripts/validate-config.mjs +44 -0
  119. package/scripts/web-review.mjs +134 -0
  120. package/skills/doctor/SKILL.md +28 -0
  121. package/skills/fix/SKILL.md +128 -0
  122. package/skills/go/SKILL.md +242 -0
  123. package/skills/init/SKILL.md +72 -0
  124. package/skills/issue/SKILL.md +128 -0
  125. package/skills/issue/references/update.md +15 -0
  126. package/skills/review/SKILL.md +157 -0
  127. package/skills/ship/SKILL.md +128 -0
  128. package/skills/status/SKILL.md +27 -0
  129. package/skills/think/SKILL.md +76 -0
  130. package/stacks/flutter.md +63 -0
  131. package/stacks/laravel.md +65 -0
  132. package/stacks/vue.md +70 -0
  133. package/templates/issue.md +99 -0
  134. package/templates/relevo.yml +59 -0
  135. package/templates/workflows/relevo-digest.yml +54 -0
  136. package/templates/workflows/relevo-go.yml +263 -0
  137. package/templates/workflows/relevo-queue.yml +72 -0
  138. package/templates/workflows/relevo-review.yml +60 -0
  139. package/templates/workflows/relevo-sla.yml +53 -0
@@ -0,0 +1,194 @@
1
+ // Reads what the `go` gates need from an issue body.
2
+ // The expected shape is documented in docs/issue-format.md.
3
+
4
+ import { section } from './issue-sections.mjs';
5
+
6
+ export const ATTACHMENT_HOSTS = ['github.com', 'user-images.githubusercontent.com', 'private-user-images.githubusercontent.com'];
7
+ export const MAX_REFERENCES = 5;
8
+
9
+ const FENCE_RE = /^\s*(```|~~~)/;
10
+ const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
11
+ const BULLET_RE = /^\s*(?:[-*+]|\d+[.)])\s+\S/;
12
+ const WHEN_RE = /^\s*(?:[-*+]\s+)?(?:\*\*|__)?(?:WHEN|GIVEN)(?:\*\*|__)?(?:\s|:)/i;
13
+ const THEN_RE = /^\s*(?:[-*+]\s+)?(?:\*\*|__)?THEN(?:\*\*|__)?(?:\s|:)/i;
14
+ const EDGE_RE = /\b(edge[ -]?cases?|casos?\s+(?:borde|l[ií]mite|extremos?))\b/i;
15
+ const DEPENDS_RE = /^\s*(?:[-*+]\s+)?Depend(?:s[- ]on|e(?:\s+de)?):\s*(.+)$/i;
16
+ const ALLOW_RE = /^\s*(?:[-*+]\s+)?Allow-paths:\s*(.+)$/i;
17
+ const PLATFORM_RE = /^\s*(?:[-*+]\s+)?Platform:\s*(.+)$/i;
18
+
19
+ /** Remove fenced code blocks so examples inside them are not counted. */
20
+ function withoutCode(body) {
21
+ const out = [];
22
+ let inFence = false;
23
+ String(body ?? '').split('\n').forEach((line) => {
24
+ if (FENCE_RE.test(line)) {
25
+ inFence = !inFence;
26
+ return;
27
+ }
28
+ if (!inFence) out.push(line);
29
+ });
30
+ return out;
31
+ }
32
+
33
+ /**
34
+ * The "Design reference" section: a bullet per reference, `- <target>: <what must match>`, plus fenced example code.
35
+ * <target> is a backticked path of the repository, a backticked owner/repo:path of a linked repository, or a link.
36
+ * Returns { present, references: [{ kind, target, repo?, path?, allowed?, match }], code, problems }.
37
+ */
38
+ export function parseDesign(body) {
39
+ const found = section(body, 'Design reference');
40
+ if (!found) return { present: false, references: [], code: 0, problems: [] };
41
+ let code = 0;
42
+ let inFence = false;
43
+ const references = [];
44
+ const problems = [];
45
+ found.text.split('\n').forEach((line) => {
46
+ if (FENCE_RE.test(line)) {
47
+ if (!inFence) code += 1;
48
+ inFence = !inFence;
49
+ return;
50
+ }
51
+ if (inFence || !/^\s*[-*+]\s+/.test(line)) return;
52
+ const bullet = line.replace(/^\s*[-*+]\s+/, '').trim();
53
+ const tick = bullet.match(/^`([^`]+)`\s*(?::\s*(.*))?$/);
54
+ const link = bullet.match(/^<?(https?:\/\/[^\s>]+?)>?\s*(?::\s+(.*))?$/);
55
+ const m = tick ?? link;
56
+ if (!m) {
57
+ problems.push(`"${bullet.slice(0, 60)}" is not a reference: write \`path\`: what must match`);
58
+ return;
59
+ }
60
+ const target = m[1].trim();
61
+ const match = (m[2] ?? '').trim();
62
+ const ref = { target, match };
63
+ if (/^https?:\/\//.test(target)) {
64
+ let host = '';
65
+ try {
66
+ host = new URL(target).hostname;
67
+ } catch { /* not a URL */ }
68
+ const ok = ATTACHMENT_HOSTS.includes(host) && (host !== 'github.com' || new URL(target).pathname.startsWith('/user-attachments/'));
69
+ Object.assign(ref, { kind: 'attachment', allowed: ok });
70
+ } else if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[^\s]+$/.test(target)) {
71
+ const [repo, ...rest] = target.split(':');
72
+ Object.assign(ref, { kind: 'repo-file', repo, path: rest.join(':') });
73
+ } else {
74
+ Object.assign(ref, { kind: 'path', path: target });
75
+ }
76
+ const path = ref.path;
77
+ if (path !== undefined && (path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || path.split(/[\\/]/).includes('..'))) {
78
+ problems.push(`"${target}" leaves the repository: use a path inside it`);
79
+ return;
80
+ }
81
+ if (match === '') problems.push(`"${target}" does not say what must match (structure, spacing, typography, colour, states)`);
82
+ references.push(ref);
83
+ });
84
+ return { present: true, references, code, problems };
85
+ }
86
+
87
+ /**
88
+ * Returns {
89
+ * scenarios: number, // complete WHEN/THEN pairs
90
+ * edgeCases: number, // bullets under an "Edge cases" heading
91
+ * dependsOn: number[], // issue numbers of this repository from "Depends-on: #N" lines
92
+ * dependsOnExternal: { repo, number }[], // issues of other repositories from "Depends-on: owner/repo#N"
93
+ * dependsOnInvalid: string[], // tokens of a Depends-on line that look like a repository but are not owner/repo#N
94
+ * allowPaths: string[], // paths and globs from "Allow-paths:" lines (dependency files the change may touch)
95
+ * platform: string[], // every name written on a "Platform:" line, in order (the platform the issue is for)
96
+ * openQuestions: string[], // the lines written under an "Open questions" heading (the template's comment and "none" excluded)
97
+ * needsClarification: boolean,// the literal text NEEDS CLARIFICATION outside code blocks
98
+ * decisionIds: string[], // D1, D2... from "### D<n>. title" headings under "Decisions"
99
+ * constraintIds: string[], // C1, C2... from "- C<n>: text" bullets under "Constraints"
100
+ * idProblems: string[], // repeated or malformed decision and constraint ids
101
+ * missing: string[], // what the spec gate reports as absent
102
+ * }
103
+ */
104
+ export function parseIssueBody(body) {
105
+ const lines = withoutCode(body);
106
+
107
+ const whens = lines.filter((l) => WHEN_RE.test(l)).length;
108
+ const thens = lines.filter((l) => THEN_RE.test(l)).length;
109
+ const scenarios = Math.min(whens, thens);
110
+
111
+ let edgeCases = 0;
112
+ let inEdge = false;
113
+ lines.forEach((line) => {
114
+ const heading = line.match(HEADING_RE);
115
+ if (heading) {
116
+ inEdge = EDGE_RE.test(heading[2]);
117
+ return;
118
+ }
119
+ if (inEdge && BULLET_RE.test(line)) edgeCases += 1;
120
+ });
121
+
122
+ // `#12` is an issue of this repository; `owner/repo#12` is an issue of another one. A token that looks like a
123
+ // repository but is not followed by `#number` (for example "acme/api 12") is invalid.
124
+ const dependsOn = [];
125
+ const dependsOnExternal = [];
126
+ const dependsOnInvalid = [];
127
+ lines.forEach((line) => {
128
+ const m = line.match(DEPENDS_RE);
129
+ if (!m) return;
130
+ [...m[1].matchAll(/(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+))?#(\d+)/g)].forEach((ref) => {
131
+ const num = Number.parseInt(ref[2], 10);
132
+ if (ref[1]) {
133
+ if (!dependsOnExternal.some((d) => d.repo === ref[1] && d.number === num)) dependsOnExternal.push({ repo: ref[1], number: num });
134
+ } else if (!dependsOn.includes(num)) dependsOn.push(num);
135
+ });
136
+ const withoutRefs = m[1].replace(/(?:[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)?#\d+/g, ' ');
137
+ [...withoutRefs.matchAll(/[A-Za-z0-9_.:-]*[A-Za-z0-9_.-]+\/[A-Za-z0-9_./:-]+/g)].forEach((bad) => {
138
+ if (!dependsOnInvalid.includes(bad[0])) dependsOnInvalid.push(bad[0]);
139
+ });
140
+ });
141
+
142
+ // Dependency files the change may touch; comma separated, with or without backticks around each path.
143
+ const allowPaths = [];
144
+ lines.forEach((line) => {
145
+ const m = line.match(ALLOW_RE);
146
+ if (!m) return;
147
+ m[1].split(',').map((p) => p.trim().replace(/^`+|`+$/g, '').trim()).filter(Boolean).forEach((p) => {
148
+ if (!allowPaths.includes(p)) allowPaths.push(p);
149
+ });
150
+ });
151
+
152
+ // The platform(s) named by the issue; resolving them against relevo.yml is platform-of.mjs's job.
153
+ const platform = [];
154
+ lines.forEach((line) => {
155
+ const m = line.match(PLATFORM_RE);
156
+ if (!m) return;
157
+ m[1].split(',').map((p) => p.trim().replace(/^`+|`+$/g, '').trim()).filter(Boolean).forEach((p) => platform.push(p));
158
+ });
159
+
160
+ const none = /^(?:none|nothing|n\/a|ninguna?|nada|-)\.?$/i;
161
+ const openQuestions = String(section(body, 'Open questions')?.text ?? '')
162
+ .replace(/<!--[\s\S]*?-->/g, '')
163
+ .split('\n')
164
+ .map((l) => l.trim())
165
+ .filter((l) => l !== '' && !none.test(l.replace(/^[-*+]\s+/, '')));
166
+ const needsClarification = lines.some((l) => /NEEDS CLARIFICATION/.test(l));
167
+
168
+ const idProblems = [];
169
+ const decisionIds = [];
170
+ String(section(body, 'Decisions')?.text ?? '').split('\n').forEach((line) => {
171
+ const h = line.match(/^###\s+(.*?)\s*$/);
172
+ if (!h) return;
173
+ const ok = h[1].match(/^(D\d+)\.\s+\S/);
174
+ if (ok) {
175
+ if (decisionIds.includes(ok[1])) idProblems.push(`the decision id ${ok[1]} is repeated`);
176
+ else decisionIds.push(ok[1]);
177
+ } else if (/^D(?:\d|[.:\s])/.test(h[1])) idProblems.push(`"### ${h[1]}" is not a decision: write "### D1. <title>"`);
178
+ });
179
+ const constraintIds = [];
180
+ String(section(body, 'Constraints')?.text ?? '').split('\n').forEach((line) => {
181
+ const b = line.match(/^\s*[-*+]\s+(C\d+)(.?)/);
182
+ if (!b) return;
183
+ if (b[2] !== ':') idProblems.push(`"${line.trim()}" is not a constraint: write "- ${b[1]}: <limit>"`);
184
+ else if (constraintIds.includes(b[1])) idProblems.push(`the constraint id ${b[1]} is repeated`);
185
+ else constraintIds.push(b[1]);
186
+ });
187
+
188
+ const missing = [];
189
+ if (scenarios === 0) missing.push('scenarios written as WHEN/THEN pairs');
190
+ if (edgeCases === 0) missing.push('edge cases (a list under an "Edge cases" heading)');
191
+
192
+ const design = parseDesign(body);
193
+ return { design, scenarios, edgeCases, dependsOn, dependsOnExternal, dependsOnInvalid, allowPaths, platform, openQuestions, needsClarification, decisionIds, constraintIds, idProblems, missing };
194
+ }
@@ -0,0 +1,43 @@
1
+ // Reads the sections of an issue body by heading, and copies them word for word.
2
+ // Implements "Sections of the issue are copied word for word" of openspec/specs/go/spec.md.
3
+ // The format is documented in docs/issue-format.md; headings are always in English.
4
+
5
+ const FENCE_RE = /^\s*(```|~~~)/;
6
+ const H2_RE = /^##\s+(.*?)\s*#*\s*$/;
7
+
8
+ /** The sections of level 2, in order: [{ name, text }]. Text is the lines under the heading, trimmed of blank lines at both ends. */
9
+ export function sections(body) {
10
+ const out = [];
11
+ let current = null;
12
+ let inFence = false;
13
+ String(body ?? '').split('\n').forEach((line) => {
14
+ if (FENCE_RE.test(line)) inFence = !inFence;
15
+ const h = !inFence && !/^###/.test(line) ? line.match(H2_RE) : null;
16
+ if (h) {
17
+ current = { name: h[1].trim(), lines: [] };
18
+ out.push(current);
19
+ return;
20
+ }
21
+ if (current) current.lines.push(line);
22
+ });
23
+ return out.map(({ name, lines }) => ({ name, text: lines.join('\n').replace(/^\s*\n/, '').replace(/\s+$/, '') }));
24
+ }
25
+
26
+ /** One section by name (case-insensitive), or null. */
27
+ export function section(body, name) {
28
+ const wanted = String(name).trim().toLowerCase();
29
+ return sections(body).find((s) => s.name.toLowerCase() === wanted) ?? null;
30
+ }
31
+
32
+ /**
33
+ * The markdown that go appends to design.md: for each name that exists and has text, "## From the issue: <Name>" and the
34
+ * text unchanged, in the order of `names`.
35
+ */
36
+ export function copyBlock(body, names) {
37
+ const blocks = [];
38
+ names.forEach((name) => {
39
+ const s = section(body, name);
40
+ if (s && s.text !== '') blocks.push(`## From the issue: ${s.name}\n\n${s.text}\n`);
41
+ });
42
+ return blocks.join('\n');
43
+ }
@@ -0,0 +1,65 @@
1
+ // Label set and state transitions.
2
+ // Implements openspec/specs/issue-lifecycle/spec.md.
3
+
4
+ // kind:
5
+ // state - lifecycle label; exactly one of the exclusive labels at a time
6
+ // exit - side exit that replaces the lifecycle label
7
+ // overlay - added next to the current state, never replaces it
8
+ export const LABELS = [
9
+ { key: 'ready', name: 'relevo:ready', color: '0e8a16', kind: 'state', description: 'Spec is finished; the agent may start' },
10
+ { key: 'queued', name: 'relevo:queued', color: 'c5def5', kind: 'state', description: 'Waiting for capacity' },
11
+ { key: 'building', name: 'relevo:building', color: 'fbca04', kind: 'state', description: 'The agent is building it' },
12
+ { key: 'built', name: 'relevo:built', color: '1d76db', kind: 'state', description: 'Built and QA passed; waiting for the assignee' },
13
+ { key: 'in-review', name: 'relevo:in-review', color: '5319e7', kind: 'state', description: 'Being reviewed on a device' },
14
+ { key: 'pr-open', name: 'relevo:pr-open', color: '0075ca', kind: 'state', description: 'Pull request open; waiting for the peer' },
15
+ { key: 'needs-spec', name: 'relevo:needs-spec', color: 'd93f0b', kind: 'exit', description: 'Spec is incomplete or defective; back to the spec writer' },
16
+ { key: 'blocked', name: 'relevo:blocked', color: 'b60205', kind: 'exit', description: 'Waiting for a dependency to merge' },
17
+ { key: 'qa-failed', name: 'relevo:qa-failed', color: 'e11d21', kind: 'exit', description: 'QA still rejected after the retries; branch is pushed' },
18
+ { key: 'changes-requested', name: 'relevo:changes-requested', color: 'f9d0c4', kind: 'exit', description: 'The reviewer asked for changes' },
19
+ { key: 'needs-reviewer', name: 'relevo:needs-reviewer', color: 'fef2c0', kind: 'overlay', description: 'No reviewer available or the review is overdue' },
20
+ ];
21
+
22
+ export const LABEL_KEYS = LABELS.map((l) => l.key);
23
+
24
+ const EXCLUSIVE_KEYS = LABELS.filter((l) => l.kind !== 'overlay').map((l) => l.key);
25
+
26
+ /** Map each label key to its effective name, applying the `labels` overrides of relevo.yml. */
27
+ export function resolveLabelNames(overrides = {}) {
28
+ const names = {};
29
+ LABELS.forEach((l) => {
30
+ names[l.key] = overrides[l.key] ?? l.name;
31
+ });
32
+ return names;
33
+ }
34
+
35
+ /** Label definitions with effective names, for creating labels in a repository. */
36
+ export function labelDefinitions(overrides = {}) {
37
+ const names = resolveLabelNames(overrides);
38
+ return LABELS.map((l) => ({ ...l, name: names[l.key] }));
39
+ }
40
+
41
+ /**
42
+ * Compute the label changes needed to move an issue to `toKey`.
43
+ * - state/exit labels are exclusive: moving to one removes any other exclusive label.
44
+ * - overlay labels are only added.
45
+ * Returns { add: [names], remove: [names] } using effective label names.
46
+ */
47
+ export function transition(currentNames, toKey, overrides = {}) {
48
+ if (!LABEL_KEYS.includes(toKey)) throw new Error(`unknown label key "${toKey}"`);
49
+ const names = resolveLabelNames(overrides);
50
+ const current = new Set(currentNames);
51
+ const target = names[toKey];
52
+ if (!EXCLUSIVE_KEYS.includes(toKey)) {
53
+ return { add: current.has(target) ? [] : [target], remove: [] };
54
+ }
55
+ const remove = EXCLUSIVE_KEYS.filter((k) => k !== toKey)
56
+ .map((k) => names[k])
57
+ .filter((n) => current.has(n));
58
+ return { add: current.has(target) ? [] : [target], remove };
59
+ }
60
+
61
+ /** Labels to remove when the issue is finished (every Relevo label). */
62
+ export function clearAll(currentNames, overrides = {}) {
63
+ const names = new Set(Object.values(resolveLabelNames(overrides)));
64
+ return { add: [], remove: currentNames.filter((n) => names.has(n)) };
65
+ }
@@ -0,0 +1,9 @@
1
+ // The languages of the messages and reports, and the choice of one from a language tag.
2
+
3
+ export const SUPPORTED_LANGUAGES = ['en', 'es'];
4
+
5
+ /** Pick the catalog for a language tag such as "es" or "es-MX"; unknown languages use English. */
6
+ export function pickLanguage(language) {
7
+ const base = String(language ?? 'en').split('-')[0];
8
+ return SUPPORTED_LANGUAGES.includes(base) ? base : 'en';
9
+ }
@@ -0,0 +1,123 @@
1
+ // Audits a device log for the error classes the review spec names:
2
+ // layout overflows, lifecycle errors, unhandled exceptions, unhandled network or data
3
+ // errors, and missing-activity errors.
4
+
5
+ const ANSI_RE = /\u001b\[[0-9;]*m/g;
6
+ const MAX_FINDINGS = 50;
7
+ const MAX_EXCERPT = 200;
8
+
9
+ const UNHANDLED_CONTEXT = /E\/flutter|Unhandled Exception|EXCEPTION CAUGHT|FATAL EXCEPTION|AndroidRuntime/;
10
+
11
+ const RULES = [
12
+ { cls: 'layout', re: /RenderFlex overflowed|BoxConstraints forces an infinite|was given unbounded (height|width)|unbounded (height|width)/i },
13
+ { cls: 'lifecycle', re: /setState\(\) called (during build|after dispose)|called after dispose\(\)|deactivated widget's ancestor/i },
14
+ { cls: 'exception', re: /Unhandled Exception|EXCEPTION CAUGHT BY|FATAL EXCEPTION|NoSuchMethodError|\bTypeError\b|\bFlutterError\b|\bUncaught\b|\bReferenceError\b|\bSyntaxError\b|Unhandled (?:promise )?rejection/ },
15
+ // A network or data exception only counts when it was not handled: it shows up as an error in the log.
16
+ { cls: 'network-data', re: /\b(DioException|SocketException|HttpException|ClientException|PostgrestException|AuthException)\b/, needs: UNHANDLED_CONTEXT },
17
+ // A driven browser: a console.error call, a request that failed, an error response, or one the origin guard stopped. Written by web-review.mjs.
18
+ { cls: 'network-data', re: /\brequestfailed\b|\bresponse [45]\d\d\b|\bblocked request\b/ },
19
+ { cls: 'console-error', re: /\bconsole\.error\b/ },
20
+ { cls: 'missing-activity', re: /ActivityNotFoundException/ },
21
+ ];
22
+
23
+ export const ERROR_CLASSES = [...new Set(RULES.map((r) => r.cls))];
24
+ export const PASSED = 'REVIEW PASSED';
25
+ export const PASSED_WITH_WAIVERS = 'REVIEW PASSED (with waivers)';
26
+ export const REJECTED = 'REVIEW REJECTED';
27
+ export const INCONCLUSIVE = 'REVIEW INCONCLUSIVE';
28
+
29
+ /**
30
+ * Scan log text. Returns { verdict, findings: [{ class, line, excerpt }], counts }.
31
+ * verdict is "REVIEW PASSED", "REVIEW REJECTED", or "REVIEW INCONCLUSIVE" when there is no evidence to judge: the log is
32
+ * empty, or `scope` (from filterByApp) says the application's process was never found in it. An inconclusive audit
33
+ * carries a `reason` and is never a pass.
34
+ */
35
+ export function auditLog(text, { scope = null } = {}) {
36
+ if (String(text ?? '').trim() === '') {
37
+ return { verdict: INCONCLUSIVE, reason: 'the device log is empty: the app was not started, the device was disconnected or the log could not be read', findings: [], counts: {} };
38
+ }
39
+ if (scope && scope.pids.length === 0) {
40
+ return { verdict: INCONCLUSIVE, reason: `no line of the log was written by ${scope.package}: restart the app and repeat the steps`, findings: [], counts: {} };
41
+ }
42
+ const findings = [];
43
+ const seen = new Set();
44
+ String(text).split('\n').forEach((raw, idx) => {
45
+ const line = raw.replace(ANSI_RE, '');
46
+ RULES.forEach((rule) => {
47
+ if (!rule.re.test(line)) return;
48
+ if (rule.needs && !rule.needs.test(line)) return;
49
+ const excerpt = line.trim().slice(0, MAX_EXCERPT);
50
+ const key = `${rule.cls}|${excerpt}`;
51
+ if (seen.has(key) || findings.length >= MAX_FINDINGS) return;
52
+ seen.add(key);
53
+ findings.push({ class: rule.cls, line: idx + 1, excerpt });
54
+ });
55
+ });
56
+ const counts = {};
57
+ findings.forEach((f) => {
58
+ counts[f.class] = (counts[f.class] ?? 0) + 1;
59
+ });
60
+ return { verdict: findings.length === 0 ? PASSED : REJECTED, findings, counts };
61
+ }
62
+
63
+ /** Markdown report of an audit. */
64
+ export function auditReport(audit, { issue, scope } = {}) {
65
+ const lines = [`# Review report${issue ? ` for #${issue}` : ''}`, '', `**${audit.verdict}**`, ''];
66
+ if (audit.verdict === INCONCLUSIVE) {
67
+ lines.push(`This audit proves nothing: ${audit.reason}. It cannot be waived.`);
68
+ return `${lines.join('\n')}\n`;
69
+ }
70
+ if (scope?.pids?.length) {
71
+ lines.push(`Scope: only the processes of \`${scope.package}\` (pid ${scope.pids.join(', ')}); other apps' log lines were ignored.`, '');
72
+ } else {
73
+ lines.push('Scope: the whole device log.', '');
74
+ }
75
+ if (audit.findings.length === 0) {
76
+ lines.push('The device log contains none of the error classes: layout, lifecycle, exceptions, network or data errors, missing activity.');
77
+ } else {
78
+ lines.push(`${audit.findings.length} finding(s):`, '');
79
+ audit.findings.forEach((f, i) => lines.push(`${i + 1}. **${f.class}** (log line ${f.line}): \`${f.excerpt.replace(/`/g, "'")}\`${f.waived ? ` — WAIVED by the reviewer: ${f.reason}` : ''}`));
80
+ }
81
+ return `${lines.join('\n')}\n`;
82
+ }
83
+
84
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
85
+ // `logcat -v time` writes "E/tag( 1234): msg"; `-v threadtime` writes "10-14 12:00:00.123 1234 1234 E tag: msg".
86
+ const PID_TIME = /^\S+\s+\S+\s+[VDIWEF]\/[^(]*\(\s*(\d+)\)/;
87
+ const PID_THREADTIME = /^\S+\s+\S+\s+(\d+)\s+\d+\s+[VDIWEF]\s/;
88
+
89
+ /** Process id of a logcat line, or null when the format has none. */
90
+ export function linePid(line) {
91
+ const m = PID_THREADTIME.exec(line) ?? PID_TIME.exec(line);
92
+ return m ? Number(m[1]) : null;
93
+ }
94
+
95
+ /** Process ids the log itself says belong to an application package (process start and crash headers). */
96
+ export function pidsForPackage(text, pkg) {
97
+ const p = escapeRe(pkg);
98
+ const re = [new RegExp(`Start proc (\\d+):${p}[/:]`), new RegExp(`Process: ${p}, PID: (\\d+)`)];
99
+ const pids = new Set();
100
+ String(text).split('\n').forEach((line) => {
101
+ re.forEach((r) => {
102
+ const m = r.exec(line);
103
+ if (m) pids.add(Number(m[1]));
104
+ });
105
+ });
106
+ return [...pids];
107
+ }
108
+
109
+ /**
110
+ * Keep only the log lines written by the processes of one application, so that other apps on the same
111
+ * device (and whatever they log) are neither audited nor quoted in the report.
112
+ * Returns { text, scope } where scope is { package, pids } when filtered, or { package, pids: [] } when the
113
+ * log never names the app's process, in which case the whole log is returned and the caller must say so.
114
+ */
115
+ export function filterByApp(text, { package: pkg, pids = [] }) {
116
+ const all = new Set([...pids.map(Number), ...pidsForPackage(text, pkg)]);
117
+ if (all.size === 0) return { text: String(text), scope: { package: pkg, pids: [] } };
118
+ const kept = String(text).split('\n').filter((line) => {
119
+ const pid = linePid(line);
120
+ return pid !== null && all.has(pid);
121
+ });
122
+ return { text: kept.join('\n'), scope: { package: pkg, pids: [...all] } };
123
+ }