@vantageos/backend-doctor 0.1.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 (108) hide show
  1. package/README.md +88 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +94 -0
  4. package/dist/contract.d.ts +18 -0
  5. package/dist/contract.js +29 -0
  6. package/dist/coverage-cli.d.ts +2 -0
  7. package/dist/coverage-cli.js +17 -0
  8. package/dist/coverage.d.ts +32 -0
  9. package/dist/coverage.js +77 -0
  10. package/dist/detectors/oracle-axis1.d.ts +86 -0
  11. package/dist/detectors/oracle-axis1.js +221 -0
  12. package/dist/detectors/oracle-axis2.d.ts +22 -0
  13. package/dist/detectors/oracle-axis2.js +149 -0
  14. package/dist/detectors/predicates.d.ts +19 -0
  15. package/dist/detectors/predicates.js +334 -0
  16. package/dist/detectors/shared.d.ts +56 -0
  17. package/dist/detectors/shared.js +103 -0
  18. package/dist/detectors/source/_scan.d.ts +43 -0
  19. package/dist/detectors/source/_scan.js +82 -0
  20. package/dist/detectors/source/backfill.d.ts +27 -0
  21. package/dist/detectors/source/backfill.js +211 -0
  22. package/dist/detectors/source/contract.d.ts +33 -0
  23. package/dist/detectors/source/contract.js +385 -0
  24. package/dist/detectors/source/identity.d.ts +47 -0
  25. package/dist/detectors/source/identity.js +410 -0
  26. package/dist/detectors/source/jobs.d.ts +10 -0
  27. package/dist/detectors/source/jobs.js +209 -0
  28. package/dist/detectors/source/lifecycle.d.ts +6 -0
  29. package/dist/detectors/source/lifecycle.js +147 -0
  30. package/dist/detectors/source/readbudget.d.ts +6 -0
  31. package/dist/detectors/source/readbudget.js +133 -0
  32. package/dist/detectors/source/refusal.d.ts +6 -0
  33. package/dist/detectors/source/refusal.js +125 -0
  34. package/dist/detectors/source/security.d.ts +37 -0
  35. package/dist/detectors/source/security.js +392 -0
  36. package/dist/detectors/source/writesafety.d.ts +14 -0
  37. package/dist/detectors/source/writesafety.js +345 -0
  38. package/dist/detectors/standard/_doc.d.ts +66 -0
  39. package/dist/detectors/standard/_doc.js +152 -0
  40. package/dist/detectors/standard/r41-frontier.d.ts +58 -0
  41. package/dist/detectors/standard/r41-frontier.js +176 -0
  42. package/dist/detectors/standard/r46-returns-validator.d.ts +61 -0
  43. package/dist/detectors/standard/r46-returns-validator.js +474 -0
  44. package/dist/detectors/standard/standard-doc.d.ts +12 -0
  45. package/dist/detectors/standard/standard-doc.js +271 -0
  46. package/dist/detectors/structural.d.ts +14 -0
  47. package/dist/detectors/structural.js +83 -0
  48. package/dist/engine-types.d.ts +10 -0
  49. package/dist/engine-types.js +1 -0
  50. package/dist/not-checkable.d.ts +22 -0
  51. package/dist/not-checkable.js +37 -0
  52. package/dist/oracle.d.ts +33 -0
  53. package/dist/oracle.js +133 -0
  54. package/dist/registry.d.ts +11 -0
  55. package/dist/registry.js +140 -0
  56. package/dist/report.d.ts +35 -0
  57. package/dist/report.js +138 -0
  58. package/dist/severity.d.ts +30 -0
  59. package/dist/severity.js +121 -0
  60. package/dist/tally.d.ts +57 -0
  61. package/dist/tally.js +152 -0
  62. package/dist/trigger.d.ts +47 -0
  63. package/dist/trigger.js +143 -0
  64. package/fixtures/backend-standard-51ec7af1.md +648 -0
  65. package/fixtures/backend-standard-a9d7877b.md +629 -0
  66. package/fixtures/mcp-app-standard-a74a042b.md +279 -0
  67. package/fixtures/mcp-extension-standard-a74a042b.md +259 -0
  68. package/fixtures/mcp-server-standard-a74a042b.md +434 -0
  69. package/fixtures/mcp-standard-a74a042b.md +354 -0
  70. package/fixtures/vp-1200-green/.pin +1 -0
  71. package/fixtures/vp-1200-green/convex/migrations.ts +350 -0
  72. package/fixtures/vp-1200-green/convex/schema.ts +1243 -0
  73. package/fixtures/vp-1200-red/.pin +1 -0
  74. package/fixtures/vp-1200-red/convex/migrations.ts +239 -0
  75. package/fixtures/vp-1200-red/convex/schema.ts +1243 -0
  76. package/fixtures/vp-by-tool-day158.csv +190 -0
  77. package/fixtures/vp-convex/.github/workflows/npm-publish.yml +163 -0
  78. package/fixtures/vp-convex/.pin +1 -0
  79. package/fixtures/vp-convex/bun.lock +783 -0
  80. package/fixtures/vp-convex/convex/auth.config.ts +8 -0
  81. package/fixtures/vp-convex/convex/briefingNotes.ts +321 -0
  82. package/fixtures/vp-convex/convex/convex.config.ts +14 -0
  83. package/fixtures/vp-convex/convex/credentials.ts +591 -0
  84. package/fixtures/vp-convex/convex/crons.ts +72 -0
  85. package/fixtures/vp-convex/convex/dashboard.ts +199 -0
  86. package/fixtures/vp-convex/convex/gumroadWebhook.ts +815 -0
  87. package/fixtures/vp-convex/convex/issues.ts +544 -0
  88. package/fixtures/vp-convex/convex/kb.ts +305 -0
  89. package/fixtures/vp-convex/convex/kbMutations.ts +192 -0
  90. package/fixtures/vp-convex/convex/kbShared.ts +56 -0
  91. package/fixtures/vp-convex/convex/licenses.ts +443 -0
  92. package/fixtures/vp-convex/convex/memories.ts +406 -0
  93. package/fixtures/vp-convex/convex/memoriesScoped.ts +220 -0
  94. package/fixtures/vp-convex/convex/messages.ts +971 -0
  95. package/fixtures/vp-convex/convex/migrations.ts +239 -0
  96. package/fixtures/vp-convex/convex/missionTemplates.ts +369 -0
  97. package/fixtures/vp-convex/convex/oauth.ts +1608 -0
  98. package/fixtures/vp-convex/convex/oauthMigrations.ts +96 -0
  99. package/fixtures/vp-convex/convex/okfBundleNode.ts +1031 -0
  100. package/fixtures/vp-convex/convex/recurringTasks.ts +386 -0
  101. package/fixtures/vp-convex/convex/schema.ts +1229 -0
  102. package/fixtures/vp-convex/convex/tasks.ts +2782 -0
  103. package/fixtures/vp-r31-green/.pin +1 -0
  104. package/fixtures/vp-r31-green/convex/migrations.ts +350 -0
  105. package/fixtures/vp-r31-red/.pin +1 -0
  106. package/fixtures/vp-r31-red/convex/migrations.ts +286 -0
  107. package/fixtures/vr-by-tool-day159.csv +89 -0
  108. package/package.json +39 -0
package/dist/tally.js ADDED
@@ -0,0 +1,152 @@
1
+ // The machine TALLY + the severity-mapped VERDICT. This closes the eyeballed-
2
+ // distribution defect (k1790pj53tx4djwfrkhq8v5kp58cn4mh): no human ever counts
3
+ // rows again. Two prior hand-typed distributions (57/40/16/2, 69/60/17/4) were
4
+ // both wrong — the tally is DERIVED here, never typed.
5
+ //
6
+ // TALLY GRANULARITY — the printed tally is over RULES, not over RESULTS. Some
7
+ // rules emit more than one DetectorResult (oracle per-row predicates reduced to
8
+ // one result today, and live/static halves in later waves); the rule-level state
9
+ // is the WORST state across that rule's results (non-conforming > could-not-judge
10
+ // > absent > present), because that is what maps to a verdict. The CLI prints the
11
+ // result count too, so the two numbers are never confused.
12
+ import { computeExitCode } from "@vantageos/mcp-doctor";
13
+ import { deriveRuleIds } from "./coverage.js";
14
+ import { deriveSeverityMap } from "./severity.js";
15
+ /** The four states, in worst-first order — the reduction picks the worst. */
16
+ const STATE_RANK = {
17
+ "non-conforming": 3,
18
+ "could-not-judge": 2,
19
+ absent: 1,
20
+ present: 0,
21
+ };
22
+ const ALL_STATES = ["present", "non-conforming", "could-not-judge", "absent"];
23
+ /** Extract the ruleId (`R-N`) a result belongs to, from its leading id token. */
24
+ export function ruleIdOf(result) {
25
+ const m = /^(R-\d+)\b/.exec(result.id);
26
+ if (!m)
27
+ throw new Error(`ruleIdOf: result id ${JSON.stringify(result.id)} carries no R-N tag`);
28
+ return m[1];
29
+ }
30
+ /**
31
+ * Reduce the engine's DetectorResults to a rule-level tally. Asserts the four
32
+ * state counts sum to the rule total (the derive-never-type invariant) and that
33
+ * every rule the standard defines is present in the results.
34
+ */
35
+ export function tally(results, ruleIds = deriveRuleIds()) {
36
+ const ruleState = new Map();
37
+ for (const r of results) {
38
+ const id = ruleIdOf(r);
39
+ const prev = ruleState.get(id);
40
+ if (prev === undefined || STATE_RANK[r.state] > STATE_RANK[prev])
41
+ ruleState.set(id, r.state);
42
+ }
43
+ const byState = {
44
+ present: 0,
45
+ "non-conforming": 0,
46
+ "could-not-judge": 0,
47
+ absent: 0,
48
+ };
49
+ for (const s of ruleState.values())
50
+ byState[s] += 1;
51
+ const ruleTotal = ruleState.size;
52
+ const sum = ALL_STATES.reduce((a, s) => a + byState[s], 0);
53
+ if (sum !== ruleTotal) {
54
+ throw new Error(`tally: state counts ${JSON.stringify(byState)} sum ${sum} != rule total ${ruleTotal}`);
55
+ }
56
+ const missing = ruleIds.filter((id) => !ruleState.has(id));
57
+ if (missing.length > 0) {
58
+ throw new Error(`tally: rules in the standard with no result: ${missing.join(", ")}`);
59
+ }
60
+ if (ruleTotal !== ruleIds.length) {
61
+ throw new Error(`tally: ${ruleTotal} rules tallied != ${ruleIds.length} derived rules`);
62
+ }
63
+ return { byState, ruleTotal, resultTotal: results.length, ruleState };
64
+ }
65
+ function emptyBySeverity() {
66
+ return { critical: [], "major-high": [], major: [], minor: [] };
67
+ }
68
+ /**
69
+ * Map the tally to react-doctor's verdict shape, severity DERIVED from the
70
+ * standard. Verdict rules (major = major-high + major combined for the gate):
71
+ * 0 critical + 0 major violations -> approve
72
+ * 0 critical + 1..5 major violations -> revise recommended
73
+ * 0 critical + >5 major violations -> revise mandatory
74
+ * >=1 critical violation -> revise mandatory (NO bypass)
75
+ * ABSTENTIONS: a could-not-judge is a human verdict owed. Any abstention blocks
76
+ * "approve" (the outcome becomes at least revise-recommended); an abstention on a
77
+ * CRITICAL rule can never be silently approved and is reported first.
78
+ *
79
+ * EXIT CODE preserves the engine's honesty:
80
+ * >=1 critical OR revise-mandatory -> 1 (a violation IS wrong)
81
+ * 1..5 major violations (revise-recommended) -> 1 (hard violations exist)
82
+ * no hard violation, abstention owed -> 2 (not looked = never a clean 0)
83
+ * approve -> 0
84
+ * We cross-check against the engine's computeExitCode and never print "approve"
85
+ * on an exit that means abstention.
86
+ */
87
+ export function verdict(t, results, severity = deriveSeverityMap()) {
88
+ const violations = emptyBySeverity();
89
+ const abstentions = emptyBySeverity();
90
+ for (const [ruleId, state] of t.ruleState) {
91
+ const sev = severity.get(ruleId);
92
+ if (sev === undefined)
93
+ throw new Error(`verdict: no severity for ${ruleId} — severity map incomplete`);
94
+ if (state === "non-conforming")
95
+ violations[sev].push(ruleId);
96
+ else if (state === "could-not-judge")
97
+ abstentions[sev].push(ruleId);
98
+ }
99
+ const criticalViolations = violations.critical.length;
100
+ const majorViolations = violations["major-high"].length + violations.major.length;
101
+ const criticalAbstentions = abstentions.critical.length;
102
+ const abstentionTotal = abstentions.critical.length +
103
+ abstentions["major-high"].length +
104
+ abstentions.major.length +
105
+ abstentions.minor.length;
106
+ let outcome;
107
+ let exitCode;
108
+ if (criticalViolations >= 1 || majorViolations > 5) {
109
+ outcome = "revise-mandatory";
110
+ exitCode = 1;
111
+ }
112
+ else if (majorViolations >= 1) {
113
+ outcome = "revise-recommended";
114
+ exitCode = 1; // hard violations present — a caller must not read a clean 0
115
+ }
116
+ else if (abstentionTotal > 0) {
117
+ // no hard violation, but a human verdict is owed (critical abstention first)
118
+ outcome = "revise-recommended";
119
+ exitCode = 2;
120
+ }
121
+ else {
122
+ outcome = "approve";
123
+ exitCode = 0;
124
+ }
125
+ const engineExitCode = computeExitCode(results);
126
+ const criticalNote = criticalViolations > 0
127
+ ? `${criticalViolations} CRITICAL violation(s) — revise mandatory, NO bypass.`
128
+ : criticalAbstentions > 0
129
+ ? `${criticalAbstentions} CRITICAL abstention(s) — a human verdict is owed; approve is blocked.`
130
+ : "no critical violation or abstention.";
131
+ const summary = `VERDICT: ${outcome} (exit ${exitCode}) — ${criticalNote} ` +
132
+ `major violations=${majorViolations}, abstentions=${abstentionTotal} ` +
133
+ `(critical=${criticalAbstentions}). engine exit=${engineExitCode}.`;
134
+ return {
135
+ outcome,
136
+ exitCode,
137
+ violations,
138
+ abstentions,
139
+ criticalViolations,
140
+ majorViolations,
141
+ criticalAbstentions,
142
+ engineExitCode,
143
+ summary,
144
+ };
145
+ }
146
+ /** The one-line machine tally string the CLI prints. */
147
+ export function tallyLine(t) {
148
+ return (`TALLY (by rule, ${t.ruleTotal} rules from ${t.resultTotal} results): ` +
149
+ `present=${t.byState.present} non-conforming=${t.byState["non-conforming"]} ` +
150
+ `could-not-judge=${t.byState["could-not-judge"]} absent=${t.byState.absent} ` +
151
+ `| sum=${t.byState.present + t.byState["non-conforming"] + t.byState["could-not-judge"] + t.byState.absent} == ${t.ruleTotal}`);
152
+ }
@@ -0,0 +1,47 @@
1
+ export declare const REFERENCE_WORKFLOW_REL = ".github/reference/backend-doctor.reference.yml";
2
+ /**
3
+ * A gitignore/GitHub-Actions style path filter. `include` are the backend-path
4
+ * shapes that MUST trigger a run; `exclude` are carve-outs that must NOT (a
5
+ * test-only, generated, or docs diff under an included tree). GitHub Actions
6
+ * `paths:` supports `!` negation, so this exact shape renders 1:1 into a workflow.
7
+ */
8
+ export interface PathFilter {
9
+ include: string[];
10
+ exclude: string[];
11
+ }
12
+ /**
13
+ * THE glob set — derived from the real Convex backend layout above. Each entry
14
+ * carries the shape it encodes so a reader can see nothing is accidental.
15
+ */
16
+ export declare const BACKEND_PATH_FILTER: PathFilter;
17
+ /**
18
+ * Convert a GitHub-Actions/gitignore-style glob to an anchored RegExp.
19
+ * `**` matches any characters including `/` (crosses directories).
20
+ * `*` matches any characters except `/` (within a single segment).
21
+ * `?` matches a single character except `/`.
22
+ * Everything else is matched literally.
23
+ */
24
+ export declare function globToRegExp(glob: string): RegExp;
25
+ /**
26
+ * The core decision: does `path` count as a backend change under `filter`?
27
+ * A path fires iff it matches an include glob AND no exclude glob.
28
+ */
29
+ export declare function pathIsBackend(path: string, filter?: PathFilter): boolean;
30
+ /**
31
+ * THE TRIGGER. Given the changed paths of a diff (e.g. `git diff --name-only`),
32
+ * decide whether backend-doctor must run.
33
+ *
34
+ * NEUTER GUARD: an empty include set is a vacuous matcher — it would fire on
35
+ * NOTHING (or, in the mirror disease, a "match everything" default fires on ALL
36
+ * paths). Either is the same bug as a scope returning empty. We throw loudly
37
+ * rather than silently degrade, so a broken filter is a RED test, never a
38
+ * cron-with-extra-steps or a rubber-stamp.
39
+ */
40
+ export declare function shouldRun(changedPaths: string[], filter?: PathFilter): boolean;
41
+ /**
42
+ * Render the shared glob set as a GitHub Actions `paths:` YAML block (2-space
43
+ * child indent under `paths:`). The reference workflow embeds the OUTPUT of this
44
+ * shape so the workflow filter and the matcher can never drift — one source.
45
+ * Excludes are emitted as `!`-negated entries, which GitHub `paths:` honours.
46
+ */
47
+ export declare function renderWorkflowPaths(filter?: PathFilter, indent?: string): string;
@@ -0,0 +1,143 @@
1
+ // D6 — THE PATH-TRIGGER. Decides, given a list of changed paths, whether
2
+ // backend-doctor MUST run. The backend-path shapes are DERIVED from what a
3
+ // Convex backend actually IS (grounded against the real /root/coding/vantage-memory
4
+ // layout, read-only):
5
+ //
6
+ // SHAPE (a) SCHEMA — convex/schema.ts (the defineSchema file).
7
+ // SHAPE (b) FUNCTION — convex/**/*.ts queries/mutations/actions (e.g.
8
+ // convex/tasks.ts, convex/http.ts, convex/lib/*.ts).
9
+ // SHAPE (c) INDEX — index definitions are `defineTable(...).index(...)`
10
+ // calls that live INSIDE the schema file (108 of them
11
+ // in VP's convex/schema.ts). So an index change IS a
12
+ // schema-file change — covered by shape (a)'s glob.
13
+ // SHAPE (d) DEPLOY CFG — convex.json (when present), package.json (the convex
14
+ // deploy scripts / convex dependency), and the Convex
15
+ // deploy/test workflows under .github/workflows
16
+ // (VP ships .github/workflows/vitest-convex.yml).
17
+ //
18
+ // ONE SOURCE OF TRUTH: BACKEND_PATH_FILTER below is the ONLY place the globs
19
+ // live. src/trigger.ts (this matcher) and the reference workflow both derive
20
+ // their path filter from it — a path moving here moves BOTH (the workflow's
21
+ // `paths:` block is generated by renderWorkflowPaths()). A matcher and a
22
+ // workflow filter that drift is the skew defect; sharing the source closes it.
23
+ //
24
+ // The reference workflow lives OUTSIDE .github/workflows/ (GitHub Actions runs
25
+ // everything under that directory regardless of a file's own "not installed"
26
+ // header — PR #8). Its path is a constant HERE, so a test that reads it resolves
27
+ // the path from one place rather than typing the literal a second time (which is
28
+ // exactly the skew that broke when the file moved).
29
+ export const REFERENCE_WORKFLOW_REL = ".github/reference/backend-doctor.reference.yml";
30
+ /**
31
+ * THE glob set — derived from the real Convex backend layout above. Each entry
32
+ * carries the shape it encodes so a reader can see nothing is accidental.
33
+ */
34
+ export const BACKEND_PATH_FILTER = {
35
+ include: [
36
+ // SHAPE (a) SCHEMA — explicit even though convex/** covers it, so the
37
+ // schema shape is visible; SHAPE (c) INDEX rides on this file.
38
+ "convex/schema.ts",
39
+ // SHAPE (b) FUNCTION — every query/mutation/action file, nested too.
40
+ "convex/**",
41
+ // SHAPE (d) DEPLOY CFG — convex project config (when present) + the npm
42
+ // manifest that carries the convex dependency and deploy scripts.
43
+ "convex.json",
44
+ "package.json",
45
+ // SHAPE (d) DEPLOY CFG — the Convex deploy / test workflows.
46
+ ".github/workflows/*convex*.yml",
47
+ ".github/workflows/*deploy*.yml",
48
+ ],
49
+ exclude: [
50
+ // A test-only diff under convex/ does not change the deployed backend.
51
+ "convex/**/*.test.ts",
52
+ // Convex codegen output is derived, never hand-edited.
53
+ "convex/_generated/**",
54
+ // A docs-only file living under convex/ is not a backend change.
55
+ "convex/**/*.md",
56
+ ],
57
+ };
58
+ /**
59
+ * Convert a GitHub-Actions/gitignore-style glob to an anchored RegExp.
60
+ * `**` matches any characters including `/` (crosses directories).
61
+ * `*` matches any characters except `/` (within a single segment).
62
+ * `?` matches a single character except `/`.
63
+ * Everything else is matched literally.
64
+ */
65
+ export function globToRegExp(glob) {
66
+ let out = "";
67
+ for (let i = 0; i < glob.length; i++) {
68
+ const c = glob[i];
69
+ if (c === "*") {
70
+ if (glob[i + 1] === "*") {
71
+ // `**/` matches zero or more directory segments (so `convex/**/*.md`
72
+ // also matches `convex/README.md`); a bare `**` matches anything.
73
+ if (glob[i + 2] === "/") {
74
+ out += "(?:.*/)?";
75
+ i += 2;
76
+ }
77
+ else {
78
+ out += ".*";
79
+ i++;
80
+ }
81
+ }
82
+ else {
83
+ out += "[^/]*";
84
+ }
85
+ }
86
+ else if (c === "?") {
87
+ out += "[^/]";
88
+ }
89
+ else if ("\\^$.|+()[]{}".includes(c)) {
90
+ out += `\\${c}`;
91
+ }
92
+ else {
93
+ out += c;
94
+ }
95
+ }
96
+ return new RegExp(`^${out}$`);
97
+ }
98
+ /** Normalise a path: strip a leading `./` and any leading `/`. */
99
+ function normalise(p) {
100
+ return p.replace(/^\.\//, "").replace(/^\/+/, "");
101
+ }
102
+ /** Does a single path match any glob in the list? */
103
+ function matchesAny(path, globs) {
104
+ return globs.some((g) => globToRegExp(g).test(path));
105
+ }
106
+ /**
107
+ * The core decision: does `path` count as a backend change under `filter`?
108
+ * A path fires iff it matches an include glob AND no exclude glob.
109
+ */
110
+ export function pathIsBackend(path, filter = BACKEND_PATH_FILTER) {
111
+ const p = normalise(path);
112
+ return matchesAny(p, filter.include) && !matchesAny(p, filter.exclude);
113
+ }
114
+ /**
115
+ * THE TRIGGER. Given the changed paths of a diff (e.g. `git diff --name-only`),
116
+ * decide whether backend-doctor must run.
117
+ *
118
+ * NEUTER GUARD: an empty include set is a vacuous matcher — it would fire on
119
+ * NOTHING (or, in the mirror disease, a "match everything" default fires on ALL
120
+ * paths). Either is the same bug as a scope returning empty. We throw loudly
121
+ * rather than silently degrade, so a broken filter is a RED test, never a
122
+ * cron-with-extra-steps or a rubber-stamp.
123
+ */
124
+ export function shouldRun(changedPaths, filter = BACKEND_PATH_FILTER) {
125
+ if (!filter.include || filter.include.length === 0) {
126
+ throw new Error("shouldRun: BACKEND_PATH_FILTER.include is EMPTY — a vacuous matcher would " +
127
+ "never fire (or, mirrored, fire on everything). Refusing to run a filter with no backend shapes.");
128
+ }
129
+ return changedPaths.some((p) => pathIsBackend(p, filter));
130
+ }
131
+ /**
132
+ * Render the shared glob set as a GitHub Actions `paths:` YAML block (2-space
133
+ * child indent under `paths:`). The reference workflow embeds the OUTPUT of this
134
+ * shape so the workflow filter and the matcher can never drift — one source.
135
+ * Excludes are emitted as `!`-negated entries, which GitHub `paths:` honours.
136
+ */
137
+ export function renderWorkflowPaths(filter = BACKEND_PATH_FILTER, indent = " ") {
138
+ const lines = [
139
+ ...filter.include.map((g) => `${indent}- "${g}"`),
140
+ ...filter.exclude.map((g) => `${indent}- "!${g}"`),
141
+ ];
142
+ return lines.join("\n");
143
+ }