tickmarkr 2.1.4 → 2.1.6
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/dist/cli/commands/stats.d.ts +21 -0
- package/dist/cli/commands/stats.js +210 -0
- package/dist/cli/commands/status.js +28 -3
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +3 -1
- package/dist/compile/collateral.d.ts +23 -2
- package/dist/compile/collateral.js +126 -11
- package/dist/compile/native.js +35 -3
- package/dist/gates/baseline.d.ts +23 -3
- package/dist/gates/baseline.js +102 -33
- package/dist/gates/llm.d.ts +1 -0
- package/dist/gates/llm.js +1 -0
- package/dist/gates/review.d.ts +9 -2
- package/dist/gates/review.js +51 -10
- package/dist/gates/run-gates.js +12 -1
- package/dist/run/daemon.js +325 -19
- package/dist/run/git.d.ts +50 -0
- package/dist/run/git.js +56 -2
- package/dist/run/journal.d.ts +28 -0
- package/dist/run/journal.js +137 -20
- package/dist/run/merge.js +13 -3
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +120 -3
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +63 -5
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare const STATS_COLUMNS: readonly ["author", "reviewer", "dispatches", "deliveries", "delivery rate", "attempts to green", "real reds", "infra reds", "within-task rescues"];
|
|
2
|
+
export interface ChannelStats {
|
|
3
|
+
author: string;
|
|
4
|
+
reviewers: string[];
|
|
5
|
+
dispatches: number;
|
|
6
|
+
deliveries: number;
|
|
7
|
+
deliveryRate: number;
|
|
8
|
+
/** Mean journal-recorded attempts among this channel's delivered tasks; null when it delivered none. */
|
|
9
|
+
attemptsToGreen: number | null;
|
|
10
|
+
realReds: number;
|
|
11
|
+
infraReds: number;
|
|
12
|
+
rescues: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface StatsReport {
|
|
15
|
+
runs: number;
|
|
16
|
+
channels: ChannelStats[];
|
|
17
|
+
}
|
|
18
|
+
/** Reduce every readable run journal under the repository's state directory. */
|
|
19
|
+
export declare function collectChannelStats(cwd?: string): StatsReport;
|
|
20
|
+
export declare function renderStats(report: StatsReport): string;
|
|
21
|
+
export declare function stats(argv: string[], cwd?: string): Promise<string>;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { classifyFailureOutput } from "../../gates/baseline.js";
|
|
4
|
+
import { stateDirName } from "../../graph/graph.js";
|
|
5
|
+
import { Journal } from "../../run/journal.js";
|
|
6
|
+
// The operator-facing schema is deliberately closed. Adding a fact to the table therefore requires
|
|
7
|
+
// adding it here as a conscious compatibility change, rather than letting incidental journal fields
|
|
8
|
+
// leak into an analytics surface.
|
|
9
|
+
export const STATS_COLUMNS = [
|
|
10
|
+
"author",
|
|
11
|
+
"reviewer",
|
|
12
|
+
"dispatches",
|
|
13
|
+
"deliveries",
|
|
14
|
+
"delivery rate",
|
|
15
|
+
"attempts to green",
|
|
16
|
+
"real reds",
|
|
17
|
+
"infra reds",
|
|
18
|
+
"within-task rescues",
|
|
19
|
+
];
|
|
20
|
+
const assignmentAuthor = (data) => {
|
|
21
|
+
const assignment = data.assignment;
|
|
22
|
+
if (!assignment || typeof assignment !== "object")
|
|
23
|
+
return undefined;
|
|
24
|
+
const { adapter, model } = assignment;
|
|
25
|
+
return typeof adapter === "string" && typeof model === "string"
|
|
26
|
+
? `${adapter}:${model}`
|
|
27
|
+
: undefined;
|
|
28
|
+
};
|
|
29
|
+
// Current journals write the reviewer into review prose; the structured field is also accepted so
|
|
30
|
+
// journals produced through gateResultJournalData retain their stronger identity representation.
|
|
31
|
+
const reviewerFrom = (data) => {
|
|
32
|
+
if (typeof data.reviewer === "string" && data.reviewer.trim())
|
|
33
|
+
return data.reviewer.trim();
|
|
34
|
+
if (typeof data.details !== "string")
|
|
35
|
+
return undefined;
|
|
36
|
+
return /\breviewer(?:\s+|:\s*)([\w@./+-]+:[\w@./+-]+)/iu.exec(data.details)?.[1];
|
|
37
|
+
};
|
|
38
|
+
const redEvidence = (data) => {
|
|
39
|
+
const fingerprints = Array.isArray(data.fingerprints)
|
|
40
|
+
? data.fingerprints.filter((value) => typeof value === "string")
|
|
41
|
+
: [];
|
|
42
|
+
const prose = [data.details, data.error, data.reason]
|
|
43
|
+
.filter((value) => typeof value === "string");
|
|
44
|
+
return [...fingerprints, ...prose].join("\n");
|
|
45
|
+
};
|
|
46
|
+
const historyFor = (tasks, taskId) => {
|
|
47
|
+
let history = tasks.get(taskId);
|
|
48
|
+
if (!history) {
|
|
49
|
+
history = { dispatches: [], deliveries: [] };
|
|
50
|
+
tasks.set(taskId, history);
|
|
51
|
+
}
|
|
52
|
+
return history;
|
|
53
|
+
};
|
|
54
|
+
const dispatchedAuthorFor = (history, data) => {
|
|
55
|
+
const attempt = typeof data.attempt === "number" && Number.isInteger(data.attempt)
|
|
56
|
+
? data.attempt
|
|
57
|
+
: undefined;
|
|
58
|
+
if (attempt !== undefined) {
|
|
59
|
+
const matched = [...history.dispatches].reverse().find((dispatch) => dispatch.attempt === attempt);
|
|
60
|
+
if (matched)
|
|
61
|
+
return matched.author;
|
|
62
|
+
}
|
|
63
|
+
return history.dispatches.at(-1)?.author;
|
|
64
|
+
};
|
|
65
|
+
const runIdsWithJournals = (cwd) => {
|
|
66
|
+
const runsDir = join(cwd, stateDirName(cwd), "runs");
|
|
67
|
+
if (!existsSync(runsDir))
|
|
68
|
+
return [];
|
|
69
|
+
return readdirSync(runsDir, { withFileTypes: true })
|
|
70
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith("run-")
|
|
71
|
+
&& existsSync(join(runsDir, entry.name, "journal.jsonl")))
|
|
72
|
+
.map((entry) => entry.name)
|
|
73
|
+
.sort();
|
|
74
|
+
};
|
|
75
|
+
/** Reduce every readable run journal under the repository's state directory. */
|
|
76
|
+
export function collectChannelStats(cwd = process.cwd()) {
|
|
77
|
+
const runIds = runIdsWithJournals(cwd);
|
|
78
|
+
const channels = new Map();
|
|
79
|
+
const channelFor = (author) => {
|
|
80
|
+
let channel = channels.get(author);
|
|
81
|
+
if (!channel) {
|
|
82
|
+
channel = {
|
|
83
|
+
author, reviewers: new Set(), dispatches: 0, deliveries: 0,
|
|
84
|
+
attemptsToGreen: 0, realReds: 0, infraReds: 0, rescues: new Set(),
|
|
85
|
+
};
|
|
86
|
+
channels.set(author, channel);
|
|
87
|
+
}
|
|
88
|
+
return channel;
|
|
89
|
+
};
|
|
90
|
+
for (const runId of runIds) {
|
|
91
|
+
const events = Journal.open(cwd, runId).read();
|
|
92
|
+
const tasks = new Map();
|
|
93
|
+
for (const [eventIndex, event] of events.entries()) {
|
|
94
|
+
if (!event.taskId)
|
|
95
|
+
continue;
|
|
96
|
+
const history = historyFor(tasks, event.taskId);
|
|
97
|
+
if (event.event === "task-dispatch") {
|
|
98
|
+
const author = assignmentAuthor(event.data);
|
|
99
|
+
if (!author)
|
|
100
|
+
continue;
|
|
101
|
+
channelFor(author).dispatches += 1;
|
|
102
|
+
history.dispatches.push({
|
|
103
|
+
author,
|
|
104
|
+
...(typeof event.data.attempt === "number" && Number.isInteger(event.data.attempt)
|
|
105
|
+
? { attempt: event.data.attempt }
|
|
106
|
+
: {}),
|
|
107
|
+
eventIndex,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (event.event === "task-done") {
|
|
112
|
+
const author = assignmentAuthor(event.data) ?? history.dispatches.at(-1)?.author;
|
|
113
|
+
if (!author)
|
|
114
|
+
continue;
|
|
115
|
+
const channel = channelFor(author);
|
|
116
|
+
channel.deliveries += 1;
|
|
117
|
+
const recordedAttempts = typeof event.data.attempts === "number"
|
|
118
|
+
&& Number.isInteger(event.data.attempts) && event.data.attempts >= 0
|
|
119
|
+
? event.data.attempts
|
|
120
|
+
: history.dispatches.filter((dispatch) => dispatch.eventIndex < eventIndex).length;
|
|
121
|
+
channel.attemptsToGreen += recordedAttempts;
|
|
122
|
+
history.deliveries.push({ author, eventIndex });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (event.event === "review-retry") {
|
|
126
|
+
const author = dispatchedAuthorFor(history, event.data);
|
|
127
|
+
if (!author)
|
|
128
|
+
continue;
|
|
129
|
+
for (const reviewer of [event.data.flaked, event.data.retried]) {
|
|
130
|
+
if (typeof reviewer === "string" && reviewer.trim())
|
|
131
|
+
channelFor(author).reviewers.add(reviewer.trim());
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (event.event !== "gate-result")
|
|
136
|
+
continue;
|
|
137
|
+
const author = dispatchedAuthorFor(history, event.data);
|
|
138
|
+
if (!author)
|
|
139
|
+
continue;
|
|
140
|
+
const channel = channelFor(author);
|
|
141
|
+
if (event.data.gate === "review") {
|
|
142
|
+
const reviewer = reviewerFrom(event.data);
|
|
143
|
+
if (reviewer)
|
|
144
|
+
channel.reviewers.add(reviewer);
|
|
145
|
+
}
|
|
146
|
+
if (event.data.pass !== false)
|
|
147
|
+
continue;
|
|
148
|
+
const infra = event.data.infra === true || classifyFailureOutput(redEvidence(event.data)) === "infra";
|
|
149
|
+
if (infra)
|
|
150
|
+
channel.infraReds += 1;
|
|
151
|
+
else
|
|
152
|
+
channel.realReds += 1;
|
|
153
|
+
}
|
|
154
|
+
// A rescue is task-matched, not attempt-matched: one edge says the failed author and delivering
|
|
155
|
+
// author faced the same task in the same run. Repeated attempts on the failed author do not make
|
|
156
|
+
// the task easier and therefore do not manufacture extra controlled comparisons.
|
|
157
|
+
for (const [taskId, history] of tasks) {
|
|
158
|
+
for (const delivery of history.deliveries) {
|
|
159
|
+
const failedAuthors = new Set(history.dispatches
|
|
160
|
+
.filter((dispatch) => dispatch.eventIndex < delivery.eventIndex && dispatch.author !== delivery.author)
|
|
161
|
+
.map((dispatch) => dispatch.author));
|
|
162
|
+
for (const failedAuthor of failedAuthors) {
|
|
163
|
+
channelFor(delivery.author).rescues.add(`${failedAuthor} → ${delivery.author} (${runId}/${taskId})`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
runs: runIds.length,
|
|
170
|
+
channels: [...channels.values()]
|
|
171
|
+
.sort((a, b) => a.author.localeCompare(b.author, "en"))
|
|
172
|
+
.map((channel) => ({
|
|
173
|
+
author: channel.author,
|
|
174
|
+
reviewers: [...channel.reviewers].sort((a, b) => a.localeCompare(b, "en")),
|
|
175
|
+
dispatches: channel.dispatches,
|
|
176
|
+
deliveries: channel.deliveries,
|
|
177
|
+
deliveryRate: channel.dispatches === 0 ? 0 : channel.deliveries / channel.dispatches,
|
|
178
|
+
attemptsToGreen: channel.deliveries === 0 ? null : channel.attemptsToGreen / channel.deliveries,
|
|
179
|
+
realReds: channel.realReds,
|
|
180
|
+
infraReds: channel.infraReds,
|
|
181
|
+
rescues: [...channel.rescues].sort((a, b) => a.localeCompare(b, "en")),
|
|
182
|
+
})),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const percent = (rate) => `${Number((rate * 100).toFixed(1))}%`;
|
|
186
|
+
export function renderStats(report) {
|
|
187
|
+
const lines = [`tickmarkr stats — ${report.runs} run${report.runs === 1 ? "" : "s"}`];
|
|
188
|
+
if (report.channels.length === 0)
|
|
189
|
+
return [...lines, "no channels"].join("\n");
|
|
190
|
+
lines.push(STATS_COLUMNS.join(" | "));
|
|
191
|
+
for (const channel of report.channels) {
|
|
192
|
+
lines.push([
|
|
193
|
+
channel.author,
|
|
194
|
+
channel.reviewers.join(", ") || "—",
|
|
195
|
+
channel.dispatches,
|
|
196
|
+
channel.deliveries,
|
|
197
|
+
percent(channel.deliveryRate),
|
|
198
|
+
channel.attemptsToGreen === null ? "—" : Number(channel.attemptsToGreen.toFixed(2)),
|
|
199
|
+
channel.realReds,
|
|
200
|
+
channel.infraReds,
|
|
201
|
+
channel.rescues.join("; ") || "—",
|
|
202
|
+
].join(" | "));
|
|
203
|
+
}
|
|
204
|
+
return lines.join("\n");
|
|
205
|
+
}
|
|
206
|
+
export async function stats(argv, cwd = process.cwd()) {
|
|
207
|
+
if (argv.length > 0)
|
|
208
|
+
throw new Error("stats takes no run id — it reads every run in the state directory");
|
|
209
|
+
return renderStats(collectChannelStats(cwd));
|
|
210
|
+
}
|
|
@@ -7,7 +7,7 @@ import { formatOwnedName, parseOwnedName, } from "../../drivers/types.js";
|
|
|
7
7
|
import { blockedTasks, graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
|
|
8
8
|
import { GATE_NAMES } from "../../graph/schema.js";
|
|
9
9
|
import { foldActivity } from "../../run/activity.js";
|
|
10
|
-
import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFailureKind, runHasEnded, } from "../../run/journal.js";
|
|
10
|
+
import { Journal, engagementComparable, isQualityFailureParkKind, preservedRefsByTask, recordedTaskFailureKind, runHasEnded, upheldFeedbackByTask, } from "../../run/journal.js";
|
|
11
11
|
import { isPidLive } from "../../run/lock.js";
|
|
12
12
|
import { normalizeGateOutcome } from "../../run/outcome.js";
|
|
13
13
|
import { desiredPanes } from "../../run/reconcile.js";
|
|
@@ -979,6 +979,21 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
979
979
|
if (!taskIds.has(taskId))
|
|
980
980
|
phases.delete(taskId);
|
|
981
981
|
const hotPhase = [...phases.values()].sort((a, b) => a.order - b.order).at(-1);
|
|
982
|
+
// OBS-738: recovery facts stay on the journal's two established reducers. The prefix fold keeps an
|
|
983
|
+
// older journal equally readable by asking upheldFeedbackByTask what was active at that exact
|
|
984
|
+
// restore boundary; current resume-restore rows additionally carry the same task for narration.
|
|
985
|
+
const preservedRefs = preservedRefsByTask(events);
|
|
986
|
+
const restoredUpheldFeedback = new Set(events.flatMap((event, index) => event.event === "resume-restore" && event.taskId
|
|
987
|
+
&& upheldFeedbackByTask(events.slice(0, index)).has(event.taskId)
|
|
988
|
+
? [event.taskId]
|
|
989
|
+
: []));
|
|
990
|
+
const recoveryLinesForTask = (taskId) => [
|
|
991
|
+
...(preservedRefs.get(taskId) ?? []).flatMap(({ ref, diffCommand }) => [
|
|
992
|
+
`preserved worktree — ${taskId} — ${ref}`,
|
|
993
|
+
` ${diffCommand}`,
|
|
994
|
+
]),
|
|
995
|
+
...(restoredUpheldFeedback.has(taskId) ? [`upheld feedback restored for ${taskId}`] : []),
|
|
996
|
+
].map(sanitizeTaskText);
|
|
982
997
|
const cells = renderedTasks.map((t) => {
|
|
983
998
|
const folded = journalRowsOnly && comparable ? taskRows.get(t.id) : undefined;
|
|
984
999
|
const st = folded?.state ?? replayed?.get(t.id) ?? t.status;
|
|
@@ -1011,7 +1026,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1011
1026
|
if (!unicode) {
|
|
1012
1027
|
// machine/CI surface — journals without phase-start stay byte-identical; new phase-aware frames
|
|
1013
1028
|
// use an ASCII spinner so pipes never receive terminal-only braille/ANSI.
|
|
1014
|
-
const rows = cells.
|
|
1029
|
+
const rows = cells.flatMap(({ t, st, merged, label, assignCol, livePhase, states, priorGraph, pane }) => {
|
|
1015
1030
|
const chain = gateChain(states, false);
|
|
1016
1031
|
const prefix = livePhase ? ` ${ASCII_SPINNER[animationFrame % ASCII_SPINNER.length]} ${t.id} ` : ` ${surfaceTaskBox(st, merged)} ${t.id} `;
|
|
1017
1032
|
const suffix = ` ${chain}${priorGraph ? ` ${PRIOR_GRAPH_MARKER}` : ""} ${livePhase ? "running" : surfaceStatusWord(st)}${label} ${assignCol}${pane ? ` pane ${pane}` : ""}`;
|
|
@@ -1020,7 +1035,10 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1020
1035
|
// can exceed the terminal width, and the pre-floor math paid for it out of the TITLE — 20 of
|
|
1021
1036
|
// 41 rows in a live 41-task run named no task at all. These rows already overflow `width`
|
|
1022
1037
|
// (a pane name is 60 columns on its own), so the floor costs wrapping, never the graph.
|
|
1023
|
-
return
|
|
1038
|
+
return [
|
|
1039
|
+
`${prefix}${shortGoal(t.title, Math.max(MACHINE_TITLE_FLOOR, width - prefix.length - suffix.length))}${suffix}`,
|
|
1040
|
+
...recoveryLinesForTask(t.id).map((line) => ` ${line}`),
|
|
1041
|
+
];
|
|
1024
1042
|
});
|
|
1025
1043
|
const zone = journalRowsOnly ? `${divider}zone ${localZoneLabel(zoneReference)}` : "";
|
|
1026
1044
|
const header = runId
|
|
@@ -1210,6 +1228,12 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1210
1228
|
return [legend(headerRow), dim(ruleRow), ...rows];
|
|
1211
1229
|
};
|
|
1212
1230
|
const gatesLegend = legend(` gates ${GATE_NAMES.map((gate) => `${gate.slice(0, 2)} ${gate}`).join(" ")}`);
|
|
1231
|
+
const recoveryLines = cells.flatMap((cell) => recoveryLinesForTask(cell.t.id));
|
|
1232
|
+
const recoverySection = recoveryLines.length === 0 ? [] : [
|
|
1233
|
+
"",
|
|
1234
|
+
` ${ok("▌")} ${title("RECOVERY")}`,
|
|
1235
|
+
...recoveryLines.map((line) => legend(` ${line}`)),
|
|
1236
|
+
];
|
|
1213
1237
|
const effort = effortPanel(g.tasks, record && !comparable ? undefined : events, boardColumns);
|
|
1214
1238
|
return {
|
|
1215
1239
|
content: boardRows([
|
|
@@ -1220,6 +1244,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1220
1244
|
taskSection,
|
|
1221
1245
|
"",
|
|
1222
1246
|
...tableRows(),
|
|
1247
|
+
...recoverySection,
|
|
1223
1248
|
gatesLegend,
|
|
1224
1249
|
"",
|
|
1225
1250
|
rule(boardColumns),
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export type CommandResult = string | {
|
|
|
5
5
|
};
|
|
6
6
|
export type CommandMap = Record<string, (argv: string[]) => Promise<CommandResult>>;
|
|
7
7
|
export declare const COMMANDS: CommandMap;
|
|
8
|
-
export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval run checked-in fixtures against every channel in isolated temp repos\n run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)\n status live run state\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> record one supervision beat for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume";
|
|
8
|
+
export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval run checked-in fixtures against every channel in isolated temp repos\n run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)\n status live run state\n stats all-run channel delivery, red, rescue, author and reviewer statistics\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> record one supervision beat for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume";
|
|
9
9
|
export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
|
|
10
10
|
out: string;
|
|
11
11
|
code: number;
|
package/dist/cli/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { report } from "./commands/report.js";
|
|
|
14
14
|
import { resume } from "./commands/resume.js";
|
|
15
15
|
import { run } from "./commands/run.js";
|
|
16
16
|
import { scope } from "./commands/scope.js";
|
|
17
|
+
import { stats } from "./commands/stats.js";
|
|
17
18
|
import { status } from "./commands/status.js";
|
|
18
19
|
import { ui } from "./commands/ui.js";
|
|
19
20
|
import { unlock } from "./commands/unlock.js";
|
|
@@ -21,7 +22,7 @@ import { verify } from "./commands/verify.js";
|
|
|
21
22
|
import { version } from "./commands/version.js";
|
|
22
23
|
const normalize = (r) => typeof r === "string" ? { out: r, code: 0 } : r;
|
|
23
24
|
export const COMMANDS = {
|
|
24
|
-
init, doctor, fleet, compile, scope, plan, run, status, resume, report, profile, ui, unlock, approve, beat, version, verify, eval: evalCommand,
|
|
25
|
+
init, doctor, fleet, compile, scope, plan, run, status, stats, resume, report, profile, ui, unlock, approve, beat, version, verify, eval: evalCommand,
|
|
25
26
|
};
|
|
26
27
|
const VERSION_FLAGS = new Set(["version", "--version", "-v"]);
|
|
27
28
|
const HELP_CMDS = new Set(["help", "-h", "--help"]);
|
|
@@ -37,6 +38,7 @@ usage: tickmarkr <command>
|
|
|
37
38
|
eval run checked-in fixtures against every channel in isolated temp repos
|
|
38
39
|
run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)
|
|
39
40
|
status live run state
|
|
41
|
+
stats all-run channel delivery, red, rescue, author and reviewer statistics
|
|
40
42
|
verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD — no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])
|
|
41
43
|
resume <id> continue a run from its journal
|
|
42
44
|
report <id> cost/quality report (--md for committable execution record)
|
|
@@ -30,6 +30,22 @@ export declare function classifyScopeOffenders(taskId: string, hard: ReadonlyArr
|
|
|
30
30
|
* Each line names the task id and at least one missing collateral test path.
|
|
31
31
|
*/
|
|
32
32
|
export declare function collateralLints(tasks: ReadonlyArray<Pick<Task, "id" | "files">>, repoRoot: string): string[];
|
|
33
|
+
/**
|
|
34
|
+
* Does every mention of `target` have an explicit, target-local READ relation?
|
|
35
|
+
*
|
|
36
|
+
* A `context:` entry grants READ authority and nothing else, so both blocking paths have to ask this
|
|
37
|
+
* before honouring one. Fail-closed by construction: every occurrence must match one of the narrow
|
|
38
|
+
* read forms below. Absence from a write-verb list is never evidence. Thus `ownedTable returns two`
|
|
39
|
+
* and the unlisted `ownedTable sorts the rows it already publishes` are refused, while an unrelated
|
|
40
|
+
* write in `the consumer adds a cache while reading ownedTable` does not revoke ownedTable's read
|
|
41
|
+
* authority. A target mentioned twice, once for reading and once ambiguously, is refused.
|
|
42
|
+
*
|
|
43
|
+
* This is intentionally a lexical representation rather than prose understanding. Keyed on NAMES:
|
|
44
|
+
* only the literal target is classified, so a dependency described conceptually stays invisible and
|
|
45
|
+
* an unfamiliar read phrasing stays refused. That closes the false-positive direction only; widen
|
|
46
|
+
* the affirmative grammar only with a control that goes red first.
|
|
47
|
+
*/
|
|
48
|
+
export declare function criterionReadsOnly(text: string, target: string): boolean;
|
|
33
49
|
export declare function newDirectoryLints(tasks: ReadonlyArray<Pick<Task, "id" | "files">>, repoRoot: string): string[];
|
|
34
50
|
/**
|
|
35
51
|
* OBS-76 class: sweep src/ for out-of-scope source files that reference a symbol the acceptance
|
|
@@ -104,7 +120,7 @@ export declare function goalDensityErrors(tasks: ReadonlyArray<Pick<Task, "id" |
|
|
|
104
120
|
* must be COMPLETE: any code path that cannot be read is its own error — the rule fails closed
|
|
105
121
|
* rather than trust a partial scan.
|
|
106
122
|
*/
|
|
107
|
-
export declare function symbolOwnershipErrors(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "acceptance"
|
|
123
|
+
export declare function symbolOwnershipErrors(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "acceptance"> & Partial<Pick<Task, "context">>>, repoRoot: string): string[];
|
|
108
124
|
/**
|
|
109
125
|
* The participation half of the config — everything the review-policy rules read. `byShape` rides
|
|
110
126
|
* along because `gates.byShape.<shape>.review: false` is a second, NON-monotone participation switch:
|
|
@@ -128,6 +144,11 @@ export declare function reviewParticipationErrors(tasks: ReadonlyArray<Pick<Task
|
|
|
128
144
|
* symbol-ownership lint and the participation config, and defaults to the invocation directory —
|
|
129
145
|
* correct for the CLI/daemon, which compile from inside the target repo.
|
|
130
146
|
*
|
|
147
|
+
* The read declaration (`context:`) rides on this parameter, on the task objects themselves — the
|
|
148
|
+
* aggregator never infers it from the repo, the config, or a sibling task. It is OPTIONAL: a caller
|
|
149
|
+
* whose task objects carry no `context` gets byte-identical verdicts to the ones it got before the
|
|
150
|
+
* field existed, because an absent declaration grants no authority at all.
|
|
151
|
+
*
|
|
131
152
|
* KNOWN GAP (R2 review, T6/T7 scope): the compile seam in src/compile/index.ts
|
|
132
153
|
* (`enforceTaskUnitContract`) does not thread `compileSource`'s `root` argument into this call, so a
|
|
133
154
|
* PROGRAMMATIC compile whose target repo differs from process.cwd() resolves the wrong root and can
|
|
@@ -138,4 +159,4 @@ export declare function reviewParticipationErrors(tasks: ReadonlyArray<Pick<Task
|
|
|
138
159
|
* critical path itself (src/gates/review.ts), so a lint this seam misses costs a late verdict rather
|
|
139
160
|
* than an unreviewed one. The compile lint is the early warning; the gate is the fail-closed backstop.
|
|
140
161
|
*/
|
|
141
|
-
export declare function taskUnitContractErrors(tasks: ReadonlyArray<Pick<Task, "id" | "goal" | "files" | "deps" | "acceptance" | "shape"
|
|
162
|
+
export declare function taskUnitContractErrors(tasks: ReadonlyArray<Pick<Task, "id" | "goal" | "files" | "deps" | "acceptance" | "shape"> & Partial<Pick<Task, "context">>>, repoRoot?: string, review?: ReviewParticipation): string[];
|
|
@@ -171,15 +171,95 @@ export function collateralLints(tasks, repoRoot) {
|
|
|
171
171
|
// v1.53 T4 (OBS-76): needles are code-shaped tokens only (camelCase / snake_case) — plain prose
|
|
172
172
|
// words never match, so prose-only criteria yield zero needles instead of alarm-fatigue noise.
|
|
173
173
|
// ponytail: token heuristic, not AST symbol resolution — promote after a version of precision data.
|
|
174
|
-
function
|
|
175
|
-
const out = new
|
|
174
|
+
function criteriaSymbolTexts(acceptance) {
|
|
175
|
+
const out = new Map();
|
|
176
176
|
for (const item of acceptance) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
177
|
+
const text = renderAcceptanceItem(item);
|
|
178
|
+
for (const tok of text.match(/\b[A-Za-z_][A-Za-z0-9_]*\b/g) ?? []) {
|
|
179
|
+
if (!tok.includes("_") && !/[a-z][A-Z]/.test(tok))
|
|
180
|
+
continue;
|
|
181
|
+
const texts = out.get(tok) ?? [];
|
|
182
|
+
if (!texts.includes(text))
|
|
183
|
+
texts.push(text);
|
|
184
|
+
out.set(tok, texts);
|
|
180
185
|
}
|
|
181
186
|
}
|
|
182
|
-
return
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
function criteriaSymbols(acceptance) {
|
|
190
|
+
return [...criteriaSymbolTexts(acceptance).keys()].sort();
|
|
191
|
+
}
|
|
192
|
+
// These are affirmative READ relations, not a list of things that are not writes. The distinction
|
|
193
|
+
// is the fail-closed boundary: an unknown predicate never earns context[] authority. A target is
|
|
194
|
+
// readable when it is the object of an explicit observation (`reading ownedTable`) or the subject
|
|
195
|
+
// of an explicitly pre-existing state (`ownedTable already publishes`). An unqualified declarative
|
|
196
|
+
// predicate (`ownedTable returns two rows`) is deliberately absent because it can demand a change.
|
|
197
|
+
const DIRECT_READ_BEFORE = /\b(?:consult|consults|consulting|inspect|inspects|inspecting|read|reads|reading|reference|references|referencing)\s+(?:(?:the|an?)\s+)?(?:(?:current|existing|unchanged)\s+)?$/i;
|
|
198
|
+
const PREEXISTING_STATE_AFTER = /^\s*(?:(?:and|or)\s+\S+\s+)*(?:(?:,\s*)?which\s+)?(?:already|currently)\s+(?:carries|carry|contains?|declares?|defines?|exposes?|holds?|owns?|provides?|publish|publishes|returns?|supplies|supply|yields?)\b/i;
|
|
199
|
+
const PASSIVE_READ_AFTER = /^\s+(?:is|remains)\s+(?:read|referenced|unchanged|untouched|as[- ]is)\b/i;
|
|
200
|
+
// Once a target-local read has been established, a same-clause continuation can revoke it. These
|
|
201
|
+
// vetoes are structural rather than an attempted exhaustive list of write verbs: an action followed
|
|
202
|
+
// by `it` / `them` / `the same ...` is target-directed but ambiguous, and a coordinated passive
|
|
203
|
+
// participle inherits the target as its subject. Both therefore fail closed. The scan stops at an
|
|
204
|
+
// actual sentence/clause boundary; a dot inside a later path is not one.
|
|
205
|
+
const TARGET_ANAPHOR_AFTER_COORDINATOR = /\b(?:and|or|then|before|after|while)\b[^,;.!?\n]{0,80}\b(?:it|them|those|the\s+same(?:\s+[A-Za-z][A-Za-z0-9_-]*)?)\b/i;
|
|
206
|
+
const TARGET_PASSIVE_AFTER_SUBJECT_READ = /\b(?:and|or|then|before|after)\s+(?:then\s+)?(?:(?:is|gets?|becomes?|being)\s+)?[A-Za-z]+(?:ed|en)\b/i;
|
|
207
|
+
function sameClauseAfterTarget(after) {
|
|
208
|
+
const boundary = after.search(/[;!?\n]|\.(?=\s|$)/);
|
|
209
|
+
return boundary === -1 ? after : after.slice(0, boundary);
|
|
210
|
+
}
|
|
211
|
+
function targetOccurrences(text, target) {
|
|
212
|
+
if (!target)
|
|
213
|
+
return [];
|
|
214
|
+
const out = [];
|
|
215
|
+
const wordAtStart = /[A-Za-z0-9_]/.test(target[0]);
|
|
216
|
+
const wordAtEnd = /[A-Za-z0-9_]/.test(target[target.length - 1]);
|
|
217
|
+
for (let at = text.indexOf(target); at !== -1; at = text.indexOf(target, at + target.length)) {
|
|
218
|
+
const before = text[at - 1];
|
|
219
|
+
const after = text[at + target.length];
|
|
220
|
+
if (wordAtStart && before !== undefined && /[A-Za-z0-9_]/.test(before))
|
|
221
|
+
continue;
|
|
222
|
+
if (wordAtEnd && after !== undefined && /[A-Za-z0-9_]/.test(after))
|
|
223
|
+
continue;
|
|
224
|
+
out.push(at);
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Does every mention of `target` have an explicit, target-local READ relation?
|
|
230
|
+
*
|
|
231
|
+
* A `context:` entry grants READ authority and nothing else, so both blocking paths have to ask this
|
|
232
|
+
* before honouring one. Fail-closed by construction: every occurrence must match one of the narrow
|
|
233
|
+
* read forms below. Absence from a write-verb list is never evidence. Thus `ownedTable returns two`
|
|
234
|
+
* and the unlisted `ownedTable sorts the rows it already publishes` are refused, while an unrelated
|
|
235
|
+
* write in `the consumer adds a cache while reading ownedTable` does not revoke ownedTable's read
|
|
236
|
+
* authority. A target mentioned twice, once for reading and once ambiguously, is refused.
|
|
237
|
+
*
|
|
238
|
+
* This is intentionally a lexical representation rather than prose understanding. Keyed on NAMES:
|
|
239
|
+
* only the literal target is classified, so a dependency described conceptually stays invisible and
|
|
240
|
+
* an unfamiliar read phrasing stays refused. That closes the false-positive direction only; widen
|
|
241
|
+
* the affirmative grammar only with a control that goes red first.
|
|
242
|
+
*/
|
|
243
|
+
export function criterionReadsOnly(text, target) {
|
|
244
|
+
const occurrences = targetOccurrences(text, target);
|
|
245
|
+
return occurrences.length > 0 && occurrences.every((at) => {
|
|
246
|
+
// Backticks quote the target, not the relation, so discard only the adjacent delimiters.
|
|
247
|
+
const before = text.slice(Math.max(0, at - 96), at).replace(/`$/, "");
|
|
248
|
+
const after = text.slice(at + target.length).replace(/^`/, "");
|
|
249
|
+
const preexisting = PREEXISTING_STATE_AFTER.exec(after);
|
|
250
|
+
const passive = PASSIVE_READ_AFTER.exec(after);
|
|
251
|
+
if (!DIRECT_READ_BEFORE.test(before) && !preexisting && !passive)
|
|
252
|
+
return false;
|
|
253
|
+
const clause = sameClauseAfterTarget(after);
|
|
254
|
+
if (TARGET_ANAPHOR_AFTER_COORDINATOR.test(clause))
|
|
255
|
+
return false;
|
|
256
|
+
// A subject-position read (`target is read` / `target already publishes`) leaves the target as
|
|
257
|
+
// the inherited subject of a coordinated passive: `... and then rewritten by the consumer` is
|
|
258
|
+
// a change demand, not a read. Object-position reads need the anaphor veto above instead.
|
|
259
|
+
const subjectRead = preexisting ?? passive;
|
|
260
|
+
return !subjectRead
|
|
261
|
+
|| !TARGET_PASSIVE_AFTER_SUBJECT_READ.test(clause.slice(subjectRead[0].length));
|
|
262
|
+
});
|
|
183
263
|
}
|
|
184
264
|
const ARCH_PAGES = ["docs/codebase/ARCHITECTURE.md", "docs/codebase/STRUCTURE.md"];
|
|
185
265
|
function topLevelSrcDir(file) {
|
|
@@ -519,8 +599,8 @@ function walkAllCode(repoRoot) {
|
|
|
519
599
|
*/
|
|
520
600
|
export function symbolOwnershipErrors(tasks, repoRoot) {
|
|
521
601
|
const perTask = tasks
|
|
522
|
-
.map((t) => ({ t,
|
|
523
|
-
.filter((x) => x.
|
|
602
|
+
.map((t) => ({ t, bySymbol: criteriaSymbolTexts(t.acceptance ?? []) }))
|
|
603
|
+
.filter((x) => x.bySymbol.size);
|
|
524
604
|
if (!perTask.length)
|
|
525
605
|
return [];
|
|
526
606
|
const { files: codeFiles, unreadable } = walkAllCode(repoRoot);
|
|
@@ -531,7 +611,7 @@ export function symbolOwnershipErrors(tasks, repoRoot) {
|
|
|
531
611
|
for (const u of unreadable)
|
|
532
612
|
errors.push(incomplete(u));
|
|
533
613
|
// resolve each symbol to its definition site(s) once, shared across tasks
|
|
534
|
-
const allSymbols = [...new Set(perTask.flatMap((x) => x.
|
|
614
|
+
const allSymbols = [...new Set(perTask.flatMap((x) => [...x.bySymbol.keys()]))];
|
|
535
615
|
const res = new Map(allSymbols.map((s) => [s, definitionRe(s)]));
|
|
536
616
|
const sites = new Map(allSymbols.map((s) => [s, []]));
|
|
537
617
|
for (const cf of codeFiles) {
|
|
@@ -548,16 +628,46 @@ export function symbolOwnershipErrors(tasks, repoRoot) {
|
|
|
548
628
|
sites.get(sym).push(cf);
|
|
549
629
|
}
|
|
550
630
|
}
|
|
551
|
-
for (const { t,
|
|
631
|
+
for (const { t, bySymbol } of perTask) {
|
|
552
632
|
// OBS-22: scopeGate accepts picomatch globs; ownership must agree.
|
|
553
633
|
const scoped = filesGlob(t.files.map((f) => f.replace(/^\.\//, "")));
|
|
554
|
-
|
|
634
|
+
// THE RULE AT THIS SITE: a `context:`
|
|
635
|
+
// entry declares a path the worker may READ, so it answers "can the worker satisfy a criterion
|
|
636
|
+
// that only reads this symbol" (yes) and never "may the worker change it" (no — that authority
|
|
637
|
+
// comes from files[] alone). Hence the exemption is conditional on the criteria that actually
|
|
638
|
+
// name the symbol, and it is asked of the SYMBOL, not of the sentence: `criterionReadsOnly` fails
|
|
639
|
+
// closed, so a criterion demanding the symbol CHANGE — and any phrasing that does not prove it
|
|
640
|
+
// reads the symbol — is still refused, and still told to widen files[] where the change is what
|
|
641
|
+
// it asks for. An unrelated in-scope write does not change that per-symbol answer. Keyed on NAMES:
|
|
642
|
+
// only a path literally written in context[] is seen here, so a
|
|
643
|
+
// read dependency an author described in prose remains invisible to this lint. That closes the
|
|
644
|
+
// false-positive direction only — nothing here narrows what the rule refuses.
|
|
645
|
+
const declaredContext = (t.context ?? []).map((f) => f.replace(/^\.\//, ""));
|
|
646
|
+
const readable = declaredContext.length ? filesGlob(declaredContext) : () => false;
|
|
647
|
+
for (const [sym, texts] of bySymbol) {
|
|
555
648
|
const defs = sites.get(sym) ?? [];
|
|
556
649
|
if (defs.length !== 1)
|
|
557
650
|
continue; // unknown or ambiguous — silent by ruling
|
|
558
651
|
const site = defs[0];
|
|
559
652
|
if (scoped(site))
|
|
560
653
|
continue; // defined inside the task's own write surface
|
|
654
|
+
const writes = !texts.every((text) => criterionReadsOnly(text, sym));
|
|
655
|
+
if (readable(site) && !writes)
|
|
656
|
+
continue; // read authority is declared and read authority is all it needs
|
|
657
|
+
if (readable(site)) {
|
|
658
|
+
errors.push(`${t.id}: criterion identifier "${sym}" is defined only in ${site}, which is declared in `
|
|
659
|
+
+ `context[] but not in files[] — a context: entry grants READ authority only, and this `
|
|
660
|
+
+ `criterion requires changing "${sym}"; add ${site} to files[] or reword the criterion to `
|
|
661
|
+
+ `require only reading it (OBS-248).`);
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
if (!writes) {
|
|
665
|
+
errors.push(`${t.id}: criterion identifier "${sym}" is defined only in ${site}, which is in neither `
|
|
666
|
+
+ `files[] nor context[] — the criterion only reads "${sym}", so declare ${site} in `
|
|
667
|
+
+ `context[]; widening files[] would grant write authority this criterion never asks for `
|
|
668
|
+
+ `(OBS-248).`);
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
561
671
|
errors.push(`${t.id}: criterion identifier "${sym}" is defined only in ${site}, which is not in files[] — `
|
|
562
672
|
+ `add ${site} to files[] or reword the criterion; a worker scoped to files[] cannot satisfy a `
|
|
563
673
|
+ `criterion whose enabling symbol lives outside it (OBS-248).`);
|
|
@@ -630,6 +740,11 @@ function activeReviewParticipation(repoRoot) {
|
|
|
630
740
|
* symbol-ownership lint and the participation config, and defaults to the invocation directory —
|
|
631
741
|
* correct for the CLI/daemon, which compile from inside the target repo.
|
|
632
742
|
*
|
|
743
|
+
* The read declaration (`context:`) rides on this parameter, on the task objects themselves — the
|
|
744
|
+
* aggregator never infers it from the repo, the config, or a sibling task. It is OPTIONAL: a caller
|
|
745
|
+
* whose task objects carry no `context` gets byte-identical verdicts to the ones it got before the
|
|
746
|
+
* field existed, because an absent declaration grants no authority at all.
|
|
747
|
+
*
|
|
633
748
|
* KNOWN GAP (R2 review, T6/T7 scope): the compile seam in src/compile/index.ts
|
|
634
749
|
* (`enforceTaskUnitContract`) does not thread `compileSource`'s `root` argument into this call, so a
|
|
635
750
|
* PROGRAMMATIC compile whose target repo differs from process.cwd() resolves the wrong root and can
|
package/dist/compile/native.js
CHANGED
|
@@ -4,6 +4,7 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
4
4
|
import { filesGlob } from "../graph/files-glob.js";
|
|
5
5
|
import picomatch from "picomatch";
|
|
6
6
|
import { GATE_NAMES, GRAPH_ROUTING_MODES, ORACLES, renderAcceptanceItem, SHAPES, TIERS, validateGraph, } from "../graph/schema.js";
|
|
7
|
+
import { criterionReadsOnly } from "./collateral.js";
|
|
7
8
|
import { CompileError, inferShape, sha256 } from "./common.js";
|
|
8
9
|
// OBS-170/OBS-184: `context:` is a promise to the worker, and nothing ever checked it could be kept.
|
|
9
10
|
// Workers run in `git worktree add <baseRef>` (run/git.ts:169-176), which materialises the base
|
|
@@ -169,20 +170,51 @@ function renderedObservables(text) {
|
|
|
169
170
|
function criterionScopeFinding(task, text, criterion, id, tests) {
|
|
170
171
|
if (task.files.length === 0)
|
|
171
172
|
return undefined; // empty files[] is deliberately unrestricted
|
|
172
|
-
const
|
|
173
|
+
const strip = (entry) => entry.replace(/^\.\//, ""); // Q120s shared matcher below
|
|
174
|
+
const inFiles = filesGlob(task.files.map(strip));
|
|
175
|
+
const context = (task.context ?? []).map(strip);
|
|
176
|
+
const inContext = context.length ? filesGlob(context) : () => false;
|
|
177
|
+
// THE RULE AT THIS SITE: `files:` clears a named path outright, because it is write authority and
|
|
178
|
+
// write authority covers reading too. `context:` clears one ONLY for a criterion that reads it and
|
|
179
|
+
// does not change it — a read declaration is not a second write surface, and unioning it in unconditionally
|
|
180
|
+
// would promote read authority to write authority for exactly the criterion that asks for the
|
|
181
|
+
// change. The question is asked per PATH — each occurrence of each declared path must carry its own
|
|
182
|
+
// affirmative read relation. A write elsewhere in the criterion therefore cannot revoke a named
|
|
183
|
+
// dependency's read exemption, and a read elsewhere cannot confer one on a path being changed. It
|
|
184
|
+
// is the same target-specific question the symbol-ownership rule asks (collateral.ts), and it fails
|
|
185
|
+
// closed: any phrasing that does not prove the criterion reads the path is refused, as it was before
|
|
186
|
+
// this check consulted the declaration at all. Keyed on NAMES: only a path literally written in files[]
|
|
187
|
+
// or context[] is matched, so a dependency the author described conceptually and never wrote out
|
|
188
|
+
// stays invisible to this check. That closes the false-positive direction only — a path in neither
|
|
189
|
+
// declaration is refused exactly as before.
|
|
190
|
+
const declared = (path) => inFiles(path) || (inContext(path) && criterionReadsOnly(text, path));
|
|
173
191
|
const named = namedCriterionPaths(text, tests);
|
|
174
192
|
const { exact, denominators } = renderedObservables(text);
|
|
175
193
|
const asserting = tests.filter((test) => exact.some((token) => test.text.includes(token))
|
|
176
194
|
|| denominators.some((denominator) => new RegExp(`(?:^|\\D)\\d+/${denominator}(?:\\D|$)`).test(test.text))).map((test) => test.path);
|
|
177
|
-
const missing = [...new Set([...named, ...asserting])].filter((path) => !
|
|
195
|
+
const missing = [...new Set([...named, ...asserting])].filter((path) => !declared(path));
|
|
178
196
|
if (missing.length === 0)
|
|
179
197
|
return undefined;
|
|
198
|
+
// The remedy names the authority the criterion actually needs. A criterion that only READS the
|
|
199
|
+
// producer is repaired by context[]; instructing its author to widen files[] is the product
|
|
200
|
+
// emitting the unsafe workaround itself. Only a criterion that requires CHANGING the producer is
|
|
201
|
+
// told to widen the write surface.
|
|
202
|
+
const readMissing = missing.filter((path) => criterionReadsOnly(text, path));
|
|
203
|
+
const writeMissing = missing.filter((path) => !criterionReadsOnly(text, path));
|
|
204
|
+
const remedy = [
|
|
205
|
+
readMissing.length === 0 ? "" : `the criterion only reads ${readMissing.length === 1 ? "it" : "them"} `
|
|
206
|
+
+ `(${readMissing.join(", ")}), so add ${readMissing.length === 1 ? "it" : "them"} to context[] — `
|
|
207
|
+
+ `files[] would grant write authority this criterion never asks for`,
|
|
208
|
+
writeMissing.length === 0 ? "" : `the criterion requires changing ${writeMissing.length === 1 ? "it" : "them"} `
|
|
209
|
+
+ `(or does not establish a read-only relation) (${writeMissing.join(", ")}), so add `
|
|
210
|
+
+ `${writeMissing.length === 1 ? "it" : "them"} to files[]`,
|
|
211
|
+
].filter(Boolean).join("; ");
|
|
180
212
|
return {
|
|
181
213
|
code: "criterion-scope",
|
|
182
214
|
fixtureId: id,
|
|
183
215
|
taskId: task.id,
|
|
184
216
|
criterion,
|
|
185
|
-
detail: `criterion names asserting file${missing.length === 1 ? "" : "s"} outside expanded files[]: ${missing.join(", ")}`,
|
|
217
|
+
detail: `criterion names asserting file${missing.length === 1 ? "" : "s"} outside expanded files[] and context[]: ${missing.join(", ")} — ${remedy}`,
|
|
186
218
|
};
|
|
187
219
|
}
|
|
188
220
|
function exportedIdentifier(root, files, identifier) {
|