@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.
- package/README.md +88 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +94 -0
- package/dist/contract.d.ts +18 -0
- package/dist/contract.js +29 -0
- package/dist/coverage-cli.d.ts +2 -0
- package/dist/coverage-cli.js +17 -0
- package/dist/coverage.d.ts +32 -0
- package/dist/coverage.js +77 -0
- package/dist/detectors/oracle-axis1.d.ts +86 -0
- package/dist/detectors/oracle-axis1.js +221 -0
- package/dist/detectors/oracle-axis2.d.ts +22 -0
- package/dist/detectors/oracle-axis2.js +149 -0
- package/dist/detectors/predicates.d.ts +19 -0
- package/dist/detectors/predicates.js +334 -0
- package/dist/detectors/shared.d.ts +56 -0
- package/dist/detectors/shared.js +103 -0
- package/dist/detectors/source/_scan.d.ts +43 -0
- package/dist/detectors/source/_scan.js +82 -0
- package/dist/detectors/source/backfill.d.ts +27 -0
- package/dist/detectors/source/backfill.js +211 -0
- package/dist/detectors/source/contract.d.ts +33 -0
- package/dist/detectors/source/contract.js +385 -0
- package/dist/detectors/source/identity.d.ts +47 -0
- package/dist/detectors/source/identity.js +410 -0
- package/dist/detectors/source/jobs.d.ts +10 -0
- package/dist/detectors/source/jobs.js +209 -0
- package/dist/detectors/source/lifecycle.d.ts +6 -0
- package/dist/detectors/source/lifecycle.js +147 -0
- package/dist/detectors/source/readbudget.d.ts +6 -0
- package/dist/detectors/source/readbudget.js +133 -0
- package/dist/detectors/source/refusal.d.ts +6 -0
- package/dist/detectors/source/refusal.js +125 -0
- package/dist/detectors/source/security.d.ts +37 -0
- package/dist/detectors/source/security.js +392 -0
- package/dist/detectors/source/writesafety.d.ts +14 -0
- package/dist/detectors/source/writesafety.js +345 -0
- package/dist/detectors/standard/_doc.d.ts +66 -0
- package/dist/detectors/standard/_doc.js +152 -0
- package/dist/detectors/standard/r41-frontier.d.ts +58 -0
- package/dist/detectors/standard/r41-frontier.js +176 -0
- package/dist/detectors/standard/r46-returns-validator.d.ts +61 -0
- package/dist/detectors/standard/r46-returns-validator.js +474 -0
- package/dist/detectors/standard/standard-doc.d.ts +12 -0
- package/dist/detectors/standard/standard-doc.js +271 -0
- package/dist/detectors/structural.d.ts +14 -0
- package/dist/detectors/structural.js +83 -0
- package/dist/engine-types.d.ts +10 -0
- package/dist/engine-types.js +1 -0
- package/dist/not-checkable.d.ts +22 -0
- package/dist/not-checkable.js +37 -0
- package/dist/oracle.d.ts +33 -0
- package/dist/oracle.js +133 -0
- package/dist/registry.d.ts +11 -0
- package/dist/registry.js +140 -0
- package/dist/report.d.ts +35 -0
- package/dist/report.js +138 -0
- package/dist/severity.d.ts +30 -0
- package/dist/severity.js +121 -0
- package/dist/tally.d.ts +57 -0
- package/dist/tally.js +152 -0
- package/dist/trigger.d.ts +47 -0
- package/dist/trigger.js +143 -0
- package/fixtures/backend-standard-51ec7af1.md +648 -0
- package/fixtures/backend-standard-a9d7877b.md +629 -0
- package/fixtures/mcp-app-standard-a74a042b.md +279 -0
- package/fixtures/mcp-extension-standard-a74a042b.md +259 -0
- package/fixtures/mcp-server-standard-a74a042b.md +434 -0
- package/fixtures/mcp-standard-a74a042b.md +354 -0
- package/fixtures/vp-1200-green/.pin +1 -0
- package/fixtures/vp-1200-green/convex/migrations.ts +350 -0
- package/fixtures/vp-1200-green/convex/schema.ts +1243 -0
- package/fixtures/vp-1200-red/.pin +1 -0
- package/fixtures/vp-1200-red/convex/migrations.ts +239 -0
- package/fixtures/vp-1200-red/convex/schema.ts +1243 -0
- package/fixtures/vp-by-tool-day158.csv +190 -0
- package/fixtures/vp-convex/.github/workflows/npm-publish.yml +163 -0
- package/fixtures/vp-convex/.pin +1 -0
- package/fixtures/vp-convex/bun.lock +783 -0
- package/fixtures/vp-convex/convex/auth.config.ts +8 -0
- package/fixtures/vp-convex/convex/briefingNotes.ts +321 -0
- package/fixtures/vp-convex/convex/convex.config.ts +14 -0
- package/fixtures/vp-convex/convex/credentials.ts +591 -0
- package/fixtures/vp-convex/convex/crons.ts +72 -0
- package/fixtures/vp-convex/convex/dashboard.ts +199 -0
- package/fixtures/vp-convex/convex/gumroadWebhook.ts +815 -0
- package/fixtures/vp-convex/convex/issues.ts +544 -0
- package/fixtures/vp-convex/convex/kb.ts +305 -0
- package/fixtures/vp-convex/convex/kbMutations.ts +192 -0
- package/fixtures/vp-convex/convex/kbShared.ts +56 -0
- package/fixtures/vp-convex/convex/licenses.ts +443 -0
- package/fixtures/vp-convex/convex/memories.ts +406 -0
- package/fixtures/vp-convex/convex/memoriesScoped.ts +220 -0
- package/fixtures/vp-convex/convex/messages.ts +971 -0
- package/fixtures/vp-convex/convex/migrations.ts +239 -0
- package/fixtures/vp-convex/convex/missionTemplates.ts +369 -0
- package/fixtures/vp-convex/convex/oauth.ts +1608 -0
- package/fixtures/vp-convex/convex/oauthMigrations.ts +96 -0
- package/fixtures/vp-convex/convex/okfBundleNode.ts +1031 -0
- package/fixtures/vp-convex/convex/recurringTasks.ts +386 -0
- package/fixtures/vp-convex/convex/schema.ts +1229 -0
- package/fixtures/vp-convex/convex/tasks.ts +2782 -0
- package/fixtures/vp-r31-green/.pin +1 -0
- package/fixtures/vp-r31-green/convex/migrations.ts +350 -0
- package/fixtures/vp-r31-red/.pin +1 -0
- package/fixtures/vp-r31-red/convex/migrations.ts +286 -0
- package/fixtures/vr-by-tool-day159.csv +89 -0
- 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;
|
package/dist/trigger.js
ADDED
|
@@ -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
|
+
}
|