honestweek 0.0.0-stage → 0.2.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/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +9 -0
- package/LICENSE +21 -0
- package/README.md +661 -2
- package/SKILL.md +129 -0
- package/bin/honestweek.mjs +240 -0
- package/honestweek.config.example.json +76 -0
- package/lib/archive.mjs +63 -0
- package/lib/atomic-json.mjs +42 -0
- package/lib/badges.mjs +43 -0
- package/lib/bounded-jsonl.mjs +39 -0
- package/lib/build.mjs +680 -0
- package/lib/carry-receipts.mjs +102 -0
- package/lib/carry-recovery.mjs +19 -0
- package/lib/claude-adapter.mjs +602 -0
- package/lib/client.mjs +397 -0
- package/lib/codex-records.mjs +167 -0
- package/lib/config.mjs +511 -0
- package/lib/curation-similarity.mjs +20 -0
- package/lib/demo/content.mjs +262 -0
- package/lib/demo/extra.mjs +1061 -0
- package/lib/demo/repo.mjs +385 -0
- package/lib/demo/week.mjs +2558 -0
- package/lib/digest-carry.mjs +538 -0
- package/lib/digest-curation.mjs +548 -0
- package/lib/digest-evidence.mjs +348 -0
- package/lib/digest-lifecycle.mjs +142 -0
- package/lib/digest-schema.mjs +58 -0
- package/lib/digest-source-bound.mjs +46 -0
- package/lib/digest-store.mjs +485 -0
- package/lib/digest.mjs +437 -0
- package/lib/discover.mjs +193 -0
- package/lib/emit/_shared.mjs +122 -0
- package/lib/emit/changelog.mjs +62 -0
- package/lib/emit/client.mjs +311 -0
- package/lib/emit/digest.mjs +53 -0
- package/lib/emit/goals-page.mjs +416 -0
- package/lib/emit/index.mjs +155 -0
- package/lib/emit/page.mjs +423 -0
- package/lib/emit/post.mjs +22 -0
- package/lib/emit/report.mjs +51 -0
- package/lib/git.mjs +568 -0
- package/lib/goals.mjs +539 -0
- package/lib/handoffs.mjs +151 -0
- package/lib/harvest.mjs +162 -0
- package/lib/history.mjs +110 -0
- package/lib/init.mjs +576 -0
- package/lib/invocation.mjs +102 -0
- package/lib/loopback-host.mjs +16 -0
- package/lib/mine/corpus.mjs +641 -0
- package/lib/mine/detect.mjs +681 -0
- package/lib/mine/draft.mjs +262 -0
- package/lib/mine/ledger.mjs +189 -0
- package/lib/mine/rank.mjs +236 -0
- package/lib/mine.mjs +381 -0
- package/lib/preview.mjs +504 -0
- package/lib/private-words.mjs +31 -0
- package/lib/problems/catalog.json +5884 -0
- package/lib/problems/checks.mjs +1454 -0
- package/lib/problems/classify.mjs +910 -0
- package/lib/problems/context.mjs +448 -0
- package/lib/problems/drafts.mjs +57 -0
- package/lib/problems/fix-tests.mjs +270 -0
- package/lib/problems/index.mjs +332 -0
- package/lib/problems/scope.mjs +401 -0
- package/lib/prompt-adapters.mjs +229 -0
- package/lib/prompt-curation.mjs +71 -0
- package/lib/prompt-identity.mjs +18 -0
- package/lib/prompt-lane.mjs +84 -0
- package/lib/prompt-lock.mjs +23 -0
- package/lib/prompt-privacy.mjs +451 -0
- package/lib/prompt-store.mjs +111 -0
- package/lib/prompts.mjs +100 -0
- package/lib/reader.mjs +189 -0
- package/lib/readers/client.json +9 -0
- package/lib/readers/default.json +5 -0
- package/lib/redact.mjs +577 -0
- package/lib/redaction-patterns.mjs +993 -0
- package/lib/replay/assemble.mjs +523 -0
- package/lib/replay/classify.mjs +464 -0
- package/lib/replay/claude.mjs +937 -0
- package/lib/replay/codex-program.mjs +459 -0
- package/lib/replay/codex.mjs +908 -0
- package/lib/replay/evidence.mjs +78 -0
- package/lib/replay/goals.mjs +287 -0
- package/lib/replay/ids.mjs +54 -0
- package/lib/replay/index.mjs +690 -0
- package/lib/replay/jsonl.mjs +102 -0
- package/lib/replay/launch.mjs +164 -0
- package/lib/replay/lookup.mjs +374 -0
- package/lib/replay/metrics.mjs +51 -0
- package/lib/replay/outcomes.mjs +233 -0
- package/lib/replay/parse-common.mjs +281 -0
- package/lib/replay/sources.mjs +205 -0
- package/lib/replay/timeline.mjs +331 -0
- package/lib/replay/views.mjs +340 -0
- package/lib/repo-identity.mjs +101 -0
- package/lib/resolve-week.mjs +178 -0
- package/lib/site/adapter.mjs +168 -0
- package/lib/site/archive.mjs +48 -0
- package/lib/site/derive.mjs +443 -0
- package/lib/site/detect.mjs +138 -0
- package/lib/site/emit-site.mjs +94 -0
- package/lib/site/fact-fence.mjs +148 -0
- package/lib/site/inspect.mjs +102 -0
- package/lib/site/load-adapter.mjs +30 -0
- package/lib/site/sessions.mjs +274 -0
- package/lib/site/transform.mjs +114 -0
- package/lib/site/values.mjs +153 -0
- package/lib/site/week-grid.mjs +49 -0
- package/lib/validate.mjs +258 -0
- package/lib/view/assets/common.css +789 -0
- package/lib/view/assets/common.js +1007 -0
- package/lib/view/assets/evidence.js +64 -0
- package/lib/view/assets/facts.js +100 -0
- package/lib/view/assets/form.js +211 -0
- package/lib/view/assets/goal.html +92 -0
- package/lib/view/assets/goal.js +638 -0
- package/lib/view/assets/insights.js +188 -0
- package/lib/view/assets/key.js +355 -0
- package/lib/view/assets/prefs.js +250 -0
- package/lib/view/assets/private-text.js +41 -0
- package/lib/view/assets/problems.css +191 -0
- package/lib/view/assets/problems.html +75 -0
- package/lib/view/assets/problems.js +1069 -0
- package/lib/view/assets/replay-model.js +704 -0
- package/lib/view/assets/replay.css +306 -0
- package/lib/view/assets/replay.html +122 -0
- package/lib/view/assets/replay.js +2079 -0
- package/lib/view/assets/search.html +48 -0
- package/lib/view/assets/search.js +615 -0
- package/lib/view/assets/sessions.js +144 -0
- package/lib/view/assets/settings.html +99 -0
- package/lib/view/assets/settings.js +183 -0
- package/lib/view/assets/setup.html +96 -0
- package/lib/view/assets/setup.js +131 -0
- package/lib/view/assets/strip.js +465 -0
- package/lib/view/codex-judge.mjs +411 -0
- package/lib/view/data.mjs +1339 -0
- package/lib/view/facts.mjs +306 -0
- package/lib/view/insights.mjs +309 -0
- package/lib/view/leaks.mjs +252 -0
- package/lib/view/problems-route.mjs +425 -0
- package/lib/view/progressive.mjs +134 -0
- package/lib/view/replay-export.mjs +252 -0
- package/lib/view/selftest/clickthrough.html +32 -0
- package/lib/view/selftest/clickthrough.js +2678 -0
- package/lib/view/server.mjs +336 -0
- package/lib/view/settings.mjs +369 -0
- package/lib/view/setup.mjs +286 -0
- package/lib/view/window.mjs +204 -0
- package/lib/view/word-index.mjs +192 -0
- package/lib/view.mjs +572 -0
- package/lib/voice-fence.mjs +213 -0
- package/lib/windows-root.mjs +17 -0
- package/lib/worktrees.mjs +260 -0
- package/package.json +44 -4
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
// lib/problems/classify.mjs: the text and command classifiers the problem checks use.
|
|
2
|
+
//
|
|
3
|
+
// Each is a named rule (RULES below), so anything one decides is an inference and says so.
|
|
4
|
+
// They read a step's raw input or a message's full text in memory, from the engine's
|
|
5
|
+
// `keepRaw` field, and return booleans, counts, fixed words or one-way fingerprints. No text
|
|
6
|
+
// they read is kept or returned.
|
|
7
|
+
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
import { HEREDOC_RE, SIMPLE_SPLIT, simpleCommands, testRunner } from '../replay/classify.mjs';
|
|
11
|
+
import { keyIsSecret, REDACTION_SOURCES } from '../redaction-patterns.mjs';
|
|
12
|
+
|
|
13
|
+
/** The rules this page adds, in plain words. A finding names the rules it rests on. */
|
|
14
|
+
export const RULES = Object.freeze({
|
|
15
|
+
'problems.completion-claim': 'An agent message reads as a completion claim when its first or last 600 characters say the work is done, fixed, working, complete, resolved, passing or all set ("is fixed", "Done.", "I\'ve implemented", "works now"). "Should work", "should be fixed" and "should pass" count as hedged claims, separately. A message that also says something is not done, not fixed or not working isn\'t counted as a claim; one that says something is not yet done, still needed, or that it hasn\'t done something takes back only a claim in the same sentence (a dot in a file name, a version or "e.g." doesn\'t end one), and a clause about your own readiness ("let me know when you\'re ready", "once you\'re all set") is left out before the message is read. The words are matched in memory; no message text is kept.',
|
|
16
|
+
'problems.claim-opens-message': 'The stricter reading a claim can reach, and it fails closed. The whole message has to be an unqualified claim: it opens with Done, Fixed, All done, All set, Complete, Completed or Implemented (or, for tests, unqualified all-pass wording: "All tests pass", "All 42 tests passed" or "Tests pass"), followed by a stop, colon, comma, exclamation mark or the line\'s end; it is 40 words or fewer; it asks nothing; it has no number after the claim; and no word anywhere in it negates, hedges, defers, admits a check wasn\'t run, or names a failure (not, but, probably, I think, should, will, next, TODO, remaining, still, unverified, check, skipped, failed, error, bug, pre-existing, and the rest of a fixed list). The message\'s text is recorded; this rule is a fixed, exact test of that text, not a reading of what it means, so a finding that otherwise rests only on recorded steps is worked out (derived). Any message that misses the bar, however it opens, stays a rule\'s reading (inferred). Whether the agent meant its words about this change is never checked.',
|
|
17
|
+
'problems.admits-no-check': 'An agent message says plainly that it did not run, test, verify or build something ("didn\'t run", "not tested", "untested", "couldn\'t verify"), or asks you to run, test or try it ("can you run it again"). The catalog counts that as the honest outcome, so these turns aren\'t flagged.',
|
|
18
|
+
'problems.tests-pass-claim': 'An agent message says tests pass or the suite is green ("all tests pass", "42 tests passing", "all green").',
|
|
19
|
+
'problems.dismisses-failure': 'An agent message calls a failure pre-existing, unrelated, flaky, already failing, or not caused by its change.',
|
|
20
|
+
'problems.check-step': 'A step counts as a check of a change when it is a test run (the engine\'s shell.test rule), a build, lint, type-check or test script (npm run build, tsc, eslint, ruff, mypy, cargo build, go vet, make), a run of code (node, python, deno, a shell script, a local ./ program), a request to a server (curl and similar), any browser or computer-use step, or a review skill call. The list is broad on purpose: a broader list flags fewer turns.',
|
|
21
|
+
'problems.runnable-file': 'An edit counts as a code change when its file isn\'t documentation (.md, .txt, images, logs) or configuration (.json, .yaml, .toml, .ini, dotfiles). The catalog names changes with nothing runnable as a known false alarm.',
|
|
22
|
+
'problems.test-file': 'A test file sits under a tests, test, __tests__, spec or specs folder, or is named *.test.*, *.spec.*, test_*.py, *_test.py, *_test.go or conftest.py. Test configuration is jest, vitest, playwright, karma or cypress config, pytest.ini, tox.ini, .mocharc or setupTests.',
|
|
23
|
+
'problems.test-weakening': 'In the old and new text of an edit to a test file (Edit, MultiEdit, a Codex patch): lines with an assertion (assert, expect(, .should., t.is(, self.assertX(, assert_eq!), lines that define a test (it(, test(, describe(, def test_, func TestX, #[test], @Test), skip or focus markers (.skip, .only, .todo, xit, @pytest.mark.skip or xfail, @unittest.skip, @Ignore, { skip: true }), early exits (process.exit(0), sys.exit(0), os._exit) and commented-out assertions. Assertions and tests are netted across every test edit in a turn, so a test moved to another file isn\'t counted. A whole-file write isn\'t compared: its old text isn\'t in the record.',
|
|
24
|
+
'problems.test-exclusion': 'A test run whose command adds --ignore, --deselect, -k "not ...", --testPathIgnorePatterns, --test-skip-pattern, --exclude, Jest\'s pass-with-no-tests flag or a trailing || true, when the previous run of the same runner by the same agent didn\'t have it.',
|
|
25
|
+
'problems.same-call': 'Two tool calls are the same call when the tool and its whole input match after whitespace is collapsed (a shell command, an edit\'s file and text, a search pattern, a connector call\'s arguments). Compared by one-way fingerprint in memory; the input itself isn\'t kept. File reads, status-style shell commands, waits and plan updates are left to the re-read and polling checks.',
|
|
26
|
+
'problems.no-change-between': 'Between two identical calls, nothing changed when the same agent made no successful edit and ran no other call that could change state (a different shell command, a connector call, an edit or a sub-agent start). Reads, searches, status checks, waits and plan updates don\'t count as changes.',
|
|
27
|
+
'problems.error-signature': 'A tool error\'s signature is the tool, an error class from fixed words (text to replace not found, matched more than once, file not read first, not found, command not found, unknown option or bad argument, permission denied, timed out, network error, syntax error, non-zero exit, other) and the first line of the error with numbers, quoted text and paths taken out. Two errors match when their signatures do, and for edits when the file is the same too.',
|
|
28
|
+
'problems.small-subagent': 'A sub-agent (a Claude Code sub-agent or a Codex child thread) that made 3 or fewer tool calls in the window.',
|
|
29
|
+
'problems.near-identical-brief': 'Two sub-agents started in the same turn have near-identical briefs when at least 80% of the distinct words in their short description are shared (for a Codex child thread, which has none, the first 300 characters of its message). Compared in memory; the briefs aren\'t kept.',
|
|
30
|
+
'problems.question-only-prompt': 'A prompt reads as a question only when it is under 400 characters, starts with a question word (why, what, how, where, which, who, when, is, are, does, do, did, can, could, should, would, was, were, has, have), ends with a question mark, and has no request words (fix, add, change, make, run, update, please, can you, go ahead, and about forty more). Conservative on purpose: "can you fix this?" is a request and isn\'t counted.',
|
|
31
|
+
'problems.in-repo-edit': 'A file counts as inside the session\'s folder when it sits under the folder the session started in, outside any .claude folder there (plans, memory and scratch files).',
|
|
32
|
+
'problems.question-edit': 'An edit counts toward scope creep wherever its file is: the session\'s own folder, another repository, a settings folder or anywhere else. Only the plan file the harness writes in plan mode is left out.',
|
|
33
|
+
'problems.outside-folder': 'An edit counts as outside the session\'s folder when its file\'s recorded path doesn\'t sit under the folder the session started in (a .claude folder inside it counts as inside). Where it went is read from the path alone: a configured repository (featured or display-only) by its configured folder, an agent\'s own settings folder by a .claude or .codex folder in the path, a temporary folder (AppData/Local/Temp, tmp, var/folders), and anywhere else. Git is never asked.',
|
|
34
|
+
'problems.plan-mode': "Plan mode is on from a recorded plan-mode notice, or a permission-mode change to plan, until a plan-mode exit notice, a change to another mode, or an approved plan. On Codex, from a turn whose recorded settings say its collaboration mode is plan, until a turn whose settings say another mode.",
|
|
35
|
+
'problems.shell-read': 'A shell command counts as a file read when the whole line is one read of one file: cat (with -n at most), head or tail with a line count, sed -n with one line range, or PowerShell\'s Get-Content (or gc) with a path and at most a line count or -Raw, optionally piped to Select-Object -First, -Skip or -Last. A second file, a pipe to another program, a redirect, a separator, a variable, a wildcard, follow mode, a byte count or an in-place edit makes it not a read. The same lines read two ways (head -n 40, sed -n 1,40p, Get-Content -TotalCount 40) count as the same read.',
|
|
36
|
+
'problems.commit-step': 'A step that committed or merged: the harness\'s own git record, the engine\'s shell.git-commit-output rule, or a successful shell command that runs git commit or gh pr merge.',
|
|
37
|
+
'problems.risky-command': 'A shell command that ran (not rejected, refused or interrupted) and, outside quoted text (a command a shell wrapper such as bash -c or eval runs counts as run, and a leading sudo, env, nohup, time, exec, xargs, command or builtin with its options, or a variable assignment, is set aside; command -v and sudo -l only look a command up, so nothing behind them runs), runs git push with --force, -f, a +refspec or --force-with-lease (not a dry run); runs git commit, push, merge, am, rebase, cherry-pick or revert with --no-verify (or git commit with -n), or points core.hooksPath at nothing (both skip git hooks); runs rm -rf, git clean with -f and without -n or --dry-run, or git branch -D; or matches the engine\'s shell.revert rule (git revert, reset --hard, checkout --, restore, stash drop). A force-push, skipped hooks, a hard reset and git clean are worth a look; a push with --force-with-lease, which refuses to overwrite work it hasn\'t seen, a recursive delete, a deleted branch and a softer revert are routine notes.',
|
|
38
|
+
'problems.secret-shape': 'A known key or token shape (sk-, gh*_, AKIA, xox, a JWT), a private-key block, a password in a web address, a Bearer or Basic credential, or a field named like a password, token, secret or API key (the same names the redactor hides a value for) whose value is 16 or more characters mixing letters and digits and not made of words. A test fixture or an example value matches the same way as a real secret; a secret with nothing around it to name it isn\'t counted. Only counts are kept, never the text.',
|
|
39
|
+
'problems.status-command': 'A status-style call checks state rather than doing work: sleep or Start-Sleep, git status, gh pr checks or view, gh run view, list or watch, curl and similar against an address, a process listing, a log tail, or a tool that reads a background job\'s output, a terminal or a server log. A shell line counts when every part of it is one of these or only filters their output. Two status calls are the same call when their status part matches word for word; every wait counts as the same call.',
|
|
40
|
+
'problems.state-change': 'A shell command that changes what a status check would report: git push, commit, merge, rebase, pull or cherry-pick; gh pr create, merge, ready, edit, close, reopen or comment; gh workflow run, gh run rerun, gh release create; npm publish or a deploy command. A status call after one of these starts a new count, as does a successful edit.',
|
|
41
|
+
'problems.ends-in-question': 'An agent message whose last two lines include one ending in a question mark. A question isn\'t always a request to you, and a gap after one isn\'t proof you were being waited on.',
|
|
42
|
+
'problems.offer-to-carry-on': 'An agent message whose last two lines offer to carry on unless you object ("I\'ll proceed unless you\'d rather I didn\'t", "unless I hear otherwise", "let me know if you\'d prefer I stop", "otherwise I\'ll") or offer to carry on once you say yes ("if you\'d like me to", "let me know if you want me to", "say the word", "if that sounds good, I\'ll"). Matched in memory; no message text is kept.',
|
|
43
|
+
'problems.ends-on-options': 'An agent message that ends on a list of two or more items (at most one short line after it), where the line or two just above the list names options, choices, decisions, alternatives, approaches or open questions, or asks which you\'d prefer, or where the items themselves are labelled Option A, Option 1 and so on. A summary list with no such lead-in isn\'t counted.',
|
|
44
|
+
'problems.names-blocker': 'An agent message names a blocker when it says it is blocked or stuck; is waiting on CI, a build, a review or background work; needs or requires your input, answer, decision, choice, credentials, access, a key, a password or a token; can\'t, couldn\'t or is unable to do something; or names an error, a failure, a crash, a denial, a missing permission, something missing, a conflict, a rate or usage limit, a login, or something only you can do or that has to be done by hand. Phrases that say there is none ("nothing blocking", "no errors", "0 failed") are taken out first. The list is broad on purpose: a broader list flags fewer turns.',
|
|
45
|
+
'problems.bare-go-ahead': 'Your reply is only a go-ahead: yes, yep, yeah, sure, ok, go, go ahead, go for it, proceed, do it, please do, continue, carry on, keep going, lgtm, sounds good, ship it or approved, with nothing else but punctuation. A reply that picks an option, adds a request or says anything more isn\'t counted.',
|
|
46
|
+
'problems.open-todos': "The agent's to-do list when a turn ends: the main agent's last TodoWrite or Codex update_plan call that went through (each states the whole list), with its items marked pending or in progress counted as open; and, for Claude Code's task tools, the TaskCreate calls the main agent made in the session less the distinct tasks any agent of the session set to completed or deleted with TaskUpdate. A list counts only when the main agent wrote to it in that turn, since one left from an earlier turn may be stale. Only the items' states are read, never their text.",
|
|
47
|
+
'problems.output-per-call': "A model call's recorded output tokens divided by the tool calls it issued (usage.call), for a call that issued at least one. Output tokens include the model's thinking, which honestweek never reads: the count is the record's, the thinking stays unread. A call whose tool-call inputs and message to you run long is left out, since what it wrote counts as output too. A call is compared only with calls by the same model in the same session, from agents whose log records a single model.",
|
|
48
|
+
'usage.call': 'A model call\'s recorded token counts: input, cache writes, cache reads and output. The context it read is input plus cache writes plus cache reads.',
|
|
49
|
+
'cost.step': 'The estimated cost of one tool call: its round trip (the context of the model call that read its result, shared equally between the results that call read) plus what it carried (how much the context grew across the step, shared equally between that step\'s results, times the later model calls in the same agent before a compaction or the agent\'s last call). Both are worked out from recorded token counts, but sharing a jump equally and assuming a result stays in context until a compaction are estimates. Most of these tokens are cache reads, which cost less per token than fresh input; this page counts tokens, not money.',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Where each rule above comes from: null when it's honestweek's own rule, not taken from a
|
|
53
|
+
* published source; a catalog source's address (lib/problems/catalog.json) when one sets it;
|
|
54
|
+
* or { url, covers } when the source sets only the part `covers` names, the rest being
|
|
55
|
+
* honestweek's own.
|
|
56
|
+
* The check cards say which, and a test keeps every rule marked (test/problems-basis.test.mjs). */
|
|
57
|
+
export const RULE_SOURCES = Object.freeze({
|
|
58
|
+
'problems.completion-claim': null,
|
|
59
|
+
'problems.claim-opens-message': null,
|
|
60
|
+
'problems.admits-no-check': null,
|
|
61
|
+
'problems.tests-pass-claim': null,
|
|
62
|
+
'problems.dismisses-failure': null,
|
|
63
|
+
'problems.check-step': null,
|
|
64
|
+
'problems.runnable-file': null,
|
|
65
|
+
'problems.test-file': null,
|
|
66
|
+
'problems.test-weakening': null,
|
|
67
|
+
'problems.test-exclusion': null,
|
|
68
|
+
'problems.same-call': null,
|
|
69
|
+
'problems.no-change-between': null,
|
|
70
|
+
'problems.error-signature': null,
|
|
71
|
+
'problems.small-subagent': null,
|
|
72
|
+
'problems.near-identical-brief': null,
|
|
73
|
+
'problems.question-only-prompt': null,
|
|
74
|
+
'problems.in-repo-edit': null,
|
|
75
|
+
'problems.question-edit': null,
|
|
76
|
+
'problems.outside-folder': null,
|
|
77
|
+
'problems.plan-mode': null,
|
|
78
|
+
'problems.shell-read': null,
|
|
79
|
+
'problems.commit-step': null,
|
|
80
|
+
'problems.risky-command': null,
|
|
81
|
+
'problems.secret-shape': null,
|
|
82
|
+
'problems.status-command': null,
|
|
83
|
+
'problems.state-change': null,
|
|
84
|
+
'problems.ends-in-question': null,
|
|
85
|
+
'problems.offer-to-carry-on': null,
|
|
86
|
+
'problems.ends-on-options': null,
|
|
87
|
+
'problems.names-blocker': null,
|
|
88
|
+
'problems.bare-go-ahead': null,
|
|
89
|
+
'problems.open-todos': null,
|
|
90
|
+
'problems.output-per-call': null,
|
|
91
|
+
'cost.step': null,
|
|
92
|
+
// The context a call read, input plus cache writes plus cache reads, as Claude Code's status line
|
|
93
|
+
// docs count it. Codex's counts are read by honestweek's own reading of its log
|
|
94
|
+
// (lib/replay/codex.mjs, codexUsage), which those docs don't cover.
|
|
95
|
+
'usage.call': { url: 'https://code.claude.com/docs/en/statusline', covers: 'Claude Code' },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---- small helpers ----------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/** A one-way fingerprint, compared in memory only and never returned. */
|
|
101
|
+
export const fingerprint = (s) => createHash('sha1').update(String(s)).digest('base64').slice(0, 16);
|
|
102
|
+
|
|
103
|
+
/** An input as a stable string: keys sorted, whitespace collapsed. Fingerprinted, never kept. */
|
|
104
|
+
export function stable(v, depth = 0) {
|
|
105
|
+
if (depth > 32) return '"…"';
|
|
106
|
+
if (typeof v === 'string') return JSON.stringify(v.replace(/\s+/g, ' ').trim());
|
|
107
|
+
if (Array.isArray(v)) return `[${v.map((x) => stable(x, depth + 1)).join(',')}]`;
|
|
108
|
+
if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stable(v[k], depth + 1)}`).join(',')}}`;
|
|
109
|
+
return JSON.stringify(v ?? null);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A path with forward slashes and no trailing slash. */
|
|
113
|
+
export const normPath = (p) => String(p ?? '').replace(/\\/g, '/').replace(/\/+$/, '');
|
|
114
|
+
|
|
115
|
+
// ---- agent messages and prompts -----------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
const CLAIM_FLAT = [
|
|
118
|
+
/\b(?:is|are|now|all|everything(?:'s| is)?)\s+(?:done|fixed|working|complete|completed|resolved|passing|green|in place|ready)\b/i,
|
|
119
|
+
/(?:^|\n)[\s#*>_-]*(?:done|fixed|complete|all set|shipped|implemented|resolved)\b[.!:,]/i,
|
|
120
|
+
/\b(?:the )?(?:fix|change|feature|bug|issue|build|page|script|check|update) (?:is|has been|was) (?:done|fixed|complete|implemented|resolved|working|in place|live)\b/i,
|
|
121
|
+
/\b(?:works|working) (?:now|correctly|as expected|end to end)\b/i,
|
|
122
|
+
/\bI(?:'ve| have) (?:fixed|implemented|resolved|completed|finished|built|added)\b/i,
|
|
123
|
+
/\ball set\b/i,
|
|
124
|
+
];
|
|
125
|
+
const CLAIM_HEDGED = [/\b(?:should|ought to)\s+(?:now\s+)?(?:work|be (?:fixed|working|resolved|good|fine)|pass|fix it)\b/i];
|
|
126
|
+
const CLAIM_NEGATED = /\b(?:not|n't|never|no longer)\s+(?:yet\s+)?(?:be\s+)?(?:done|fixed|working|complete|completed|passing|resolved|ready)\b/i;
|
|
127
|
+
// Work still to do ("I haven't wired the UI yet", "you still need to restart it") takes back only
|
|
128
|
+
// a claim in its own sentence: "All tests pass. I haven't pushed yet." still claims the tests pass.
|
|
129
|
+
const WORK_LEFT = /\b(?:have|has)\s?n[o']t\s+(?:yet\s+)?[a-z]+ed\b|\bnot yet\b|\bstill (?:need|needs|have to|has to)\b/i;
|
|
130
|
+
// Clauses about the person's own readiness ("let me know when you're ready", "once you're all
|
|
131
|
+
// set") are taken out before a message is read for a claim: they describe the person, not the work.
|
|
132
|
+
const ADDRESSED = /\b(?:when|once|if|whenever|until)\s+(?:you|you're|you are|you've)\b[^.?!\n,;:]*|\b(?:let me know|tell me)\b[^.?!\n,;:]*/gi;
|
|
133
|
+
const ADMITS_NO_CHECK = /\b(?:did ?n[o']t|have ?n[o']t|has ?n[o']t|could ?n[o']t|was ?n[o']t able to|unable to|did not get to|not yet)\s+(?:run|test|verif|check|build|exercis)|\buntested\b|\bnot (?:been )?(?:tested|verified|run|built|checked)\b|\b(?:(?:can|could|would|will) you|please) (?:please )?(?:(?:re-?)?run|test|try)\b|\b(?:(?:can|could|would|will) you|please) (?:please )?(?:check|verify|confirm)\s+(?:(?:that )?(?:it|this|that) works|the (?:fix|change|build|tests?|suite))\b/i;
|
|
134
|
+
const TESTS_PASS = /\b(?:all\s+)?(?:\d[\d,]*\s+)?tests?\s+(?:now\s+)?(?:pass|passes|passing|passed|are green|green)\b|\bsuite (?:passes|is green|passed)\b|\ball green\b/i;
|
|
135
|
+
const DISMISSES = /\bpre-?existing\b|\bunrelated (?:to|failure|test)|\balready (?:failing|broken|red)\b|\bflaky\b|\bnot (?:caused|introduced) by (?:my|this|these|the) change/i;
|
|
136
|
+
|
|
137
|
+
/** Booleans for one agent message (problems.completion-claim and its neighbours). The zones a
|
|
138
|
+
* completion claim sits in are the message's opening and closing stretch. */
|
|
139
|
+
export function classifyAgentText(text) {
|
|
140
|
+
const s = String(text ?? '');
|
|
141
|
+
const zone = (s.length > 1200 ? `${s.slice(0, 600)}\n${s.slice(-600)}` : s).replace(ADDRESSED, ' ');
|
|
142
|
+
const negated = CLAIM_NEGATED.test(zone);
|
|
143
|
+
const flat = !negated && CLAIM_FLAT.some((re) => claimStands(zone, re));
|
|
144
|
+
const hedged = !negated && !flat && CLAIM_HEDGED.some((re) => claimStands(zone, re));
|
|
145
|
+
return { flat, hedged, testsPass: !negated && claimStands(zone, TESTS_PASS), admitsNoCheck: ADMITS_NO_CHECK.test(s), dismisses: DISMISSES.test(s) };
|
|
146
|
+
}
|
|
147
|
+
// A sentence ends at . ! or ? before a space or the end, so the dot in a file name, a version or
|
|
148
|
+
// an abbreviation (lib/foo.mjs, 1.2, e.g.) doesn't split one; and at a line break, unless the next
|
|
149
|
+
// line carries on in lower case (a soft wrap).
|
|
150
|
+
const ABBREV = /\b(?:e\.g|i\.e|etc|vs|cf)\.$/i;
|
|
151
|
+
const endsSentence = (zone, i) =>
|
|
152
|
+
zone[i] === '\n'
|
|
153
|
+
? !/^[ \t]*[a-z]/.test(zone.slice(i + 1, i + 40))
|
|
154
|
+
: /[.!?]/.test(zone[i]) && (i + 1 >= zone.length || /\s/.test(zone[i + 1])) && !(zone[i] === '.' && ABBREV.test(zone.slice(Math.max(0, i - 5), i + 1)));
|
|
155
|
+
/** True when a match of re sits in a sentence that doesn't also say work is left (WORK_LEFT). */
|
|
156
|
+
function claimStands(zone, re) {
|
|
157
|
+
for (const m of zone.matchAll(new RegExp(re.source, re.flags.replace('g', '') + 'g'))) {
|
|
158
|
+
const start = m.index + m[0].length - m[0].trimStart().length;
|
|
159
|
+
const end = m.index + m[0].length;
|
|
160
|
+
let a = start;
|
|
161
|
+
while (a > 0 && !endsSentence(zone, a - 1)) a--;
|
|
162
|
+
let b = Math.max(start, end - 1);
|
|
163
|
+
while (b < zone.length && !endsSentence(zone, b)) b++;
|
|
164
|
+
if (!WORK_LEFT.test(zone.slice(a, b + 1))) return true;
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// problems.claim-opens-message: the whole message is an unqualified claim, or it isn't strict.
|
|
170
|
+
// It fails closed: the message opens with the claim, is short, asks nothing, has no number after
|
|
171
|
+
// the claim, and has no word from DOUBT anywhere in it. A message that hedges, defers, admits no
|
|
172
|
+
// check or reports a failure, in any words, stays a rule's reading (inferred), however it opens.
|
|
173
|
+
const OPENS_DONE = /^(?:done|fixed|all done|all set|complete|completed|implemented)(?:[.!:,\n]|$)/i;
|
|
174
|
+
// Unqualified all-pass wording only: "All tests pass", "All 42 tests passed", "Tests pass". A
|
|
175
|
+
// count without "all" before it ("42 tests passed") can sit beside a failure count, so it isn't one.
|
|
176
|
+
const OPENS_TESTS_PASS = /^(?:all\s+(?:\d[\d,]*\s+)?tests?|tests)\s+(?:now\s+)?(?:pass|passed|are passing|are green)(?:[.!:,\n]|$)/i;
|
|
177
|
+
export const STRICT_MAX_WORDS = 40;
|
|
178
|
+
// Words that negate, hedge, defer, admit no check, or name a failure. A stem ends in "-" and
|
|
179
|
+
// matches a word's start ("fail-" covers failed, failing and failures); the rest match whole.
|
|
180
|
+
export const DOUBT_WORDS = Object.freeze([
|
|
181
|
+
// negation and exceptions
|
|
182
|
+
'not', 'no', 'never', 'none', 'nothing', 'nor', 'without', 'cannot', "can't", "won't", "isn't", "aren't", "doesn't", "didn't", "don't", "hasn't", "haven't", "wasn't", "weren't", "couldn't", "wouldn't", "shouldn't", 'but', 'except', 'unless', 'although', 'though', 'however', 'if', 'instead',
|
|
183
|
+
// hedges
|
|
184
|
+
'may', 'might', 'maybe', 'perhaps', 'probabl-', 'possibl-', 'likely', 'unlikely', 'hope-', 'think', 'believ-', 'guess-', 'suppos-', 'assum-', 'seem-', 'appear-', 'should', 'could', 'would', 'sure', 'unsure', 'uncertain-', 'unclear', 'roughly', 'mostly', 'partial-', 'partly', 'some', 'somewhat', 'almost', 'nearly', 'mainly',
|
|
185
|
+
// deferral and work left
|
|
186
|
+
'will', "i'll", "we'll", "you'll", 'next', 'then', 'later', 'todo', 'fixme', 'tbd', 'remain-', 'still', 'yet', 'pending', 'follow-', 'left', 'need-', 'must', 'wip', 'until', 'once', 'workaround-',
|
|
187
|
+
// a claim limited to part of the work, or work not tried
|
|
188
|
+
'only', 'just', 'happy path', 'so far', 'for now', 'out of time', 'ran out', 'unfinish-', 'incomplet-', 'untried', 'untri-', 'unrun',
|
|
189
|
+
// checks not run
|
|
190
|
+
'unverif-', 'verif-', 'untest-', 'uncheck-', 'check-', 'skip-',
|
|
191
|
+
// failure, in any words
|
|
192
|
+
'fail-', 'error-', 'broke-', 'break-', 'red', 'flak-', 'pre-exist-', 'preexist-', 'exist-', 'issue-', 'bug-', 'problem-', 'warn-', 'crash-', 'regress-', 'wrong', 'incorrect-', 'miss-', 'bad', 'timeout-', 'timed',
|
|
193
|
+
]);
|
|
194
|
+
const escRe = (w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
195
|
+
const DOUBT = new RegExp(
|
|
196
|
+
`\\b(?:${DOUBT_WORDS.filter((w) => w.endsWith('-')).map((w) => escRe(w.slice(0, -1))).join('|')})|\\b(?:${DOUBT_WORDS.filter((w) => !w.endsWith('-')).map(escRe).join('|')})(?![\\w'])`,
|
|
197
|
+
'i',
|
|
198
|
+
);
|
|
199
|
+
/** 'done' or 'tests-pass' when the whole message is an unqualified claim (problems.claim-opens-message); null otherwise. */
|
|
200
|
+
export function strictClaim(text) {
|
|
201
|
+
const s = String(text ?? '')
|
|
202
|
+
.replace(/[’‘]/g, "'")
|
|
203
|
+
.split(/\r?\n/)
|
|
204
|
+
.map((l) => l.replace(/^[\s#>*_-]+/, '').replace(/[*_`]+/g, '').trim())
|
|
205
|
+
.filter(Boolean)
|
|
206
|
+
.join('\n');
|
|
207
|
+
const opener = s.match(OPENS_DONE) ?? s.match(OPENS_TESTS_PASS);
|
|
208
|
+
if (!opener) return null;
|
|
209
|
+
const rest = s.slice(opener[0].length);
|
|
210
|
+
if (s.split(/\s+/).length > STRICT_MAX_WORDS || s.includes('?') || /\d/.test(rest) || DOUBT.test(s)) return null;
|
|
211
|
+
return OPENS_DONE.test(s) ? 'done' : 'tests-pass';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
/** True when one of an agent message's last two lines ends in a question mark (problems.ends-in-question). */
|
|
216
|
+
export function endsWithQuestion(text) {
|
|
217
|
+
const lines = String(text ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
218
|
+
return lines.slice(-2).some((l) => /\?["')\]*_`]*$/.test(l));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- how a turn ends: offers, option lists and blockers ---------------------------------------
|
|
222
|
+
|
|
223
|
+
const lastLines = (text, n) => String(text ?? '').replace(/[’‘]/g, "'").split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(-n);
|
|
224
|
+
|
|
225
|
+
// problems.offer-to-carry-on. The first group carries on unless you object; the second waits for a yes.
|
|
226
|
+
const OFFER = new RegExp(
|
|
227
|
+
[
|
|
228
|
+
"\\bunless (?:you(?:'d| would)? (?:prefer|rather|want|like|say|object|tell|stop|disagree|think|see|let)|(?:i|we) hear|told|instructed|stopped|(?:there's|there is) (?:an )?objection|someone objects)",
|
|
229
|
+
"\\b(?:let me know|tell me|say so|shout|speak up|stop me) if you(?:'d| would)? (?:rather|prefer|object|disagree)",
|
|
230
|
+
"\\b(?:let me know|tell me) if (?:that's not|this isn't|you don't want)",
|
|
231
|
+
"\\bif you(?:'d| would) (?:rather|prefer) (?:i|that i|me)\\b",
|
|
232
|
+
"\\bif you (?:don't|do not) want me to\\b",
|
|
233
|
+
"\\b(?:otherwise|if not|if i don't hear (?:back|otherwise)),? i(?:'ll| will)\\b",
|
|
234
|
+
"\\bif you(?:'d| would) like(?: me)? to\\b",
|
|
235
|
+
"\\blet me know if you(?:'d)? (?:want|like) me to\\b",
|
|
236
|
+
"\\bsay the word\\b|\\bjust say (?:go|yes)\\b",
|
|
237
|
+
"\\bhappy to (?:continue|proceed|keep going|carry on|go ahead|start|finish|move on)\\b",
|
|
238
|
+
"\\bif (?:that|this|it) (?:sounds|looks|works)\\b[^.?!]*\\bi(?:'ll| will| can)\\b",
|
|
239
|
+
].join('|'),
|
|
240
|
+
'i',
|
|
241
|
+
);
|
|
242
|
+
/** True when an agent message's last two lines offer to carry on (problems.offer-to-carry-on). */
|
|
243
|
+
export const offersToCarryOn = (text) => OFFER.test(lastLines(text, 2).join('\n'));
|
|
244
|
+
|
|
245
|
+
const LIST_ITEM = /^(?:[-*•+]|\d{1,2}[.)]|\(?[a-z][.)])\s+\S/i;
|
|
246
|
+
const OPTIONS_LEAD = /\b(?:options?|choices?|decisions?|alternatives?|approaches|open questions?|questions for you|(?:which|what) (?:would you|do you|should i|one)|which (?:you'd|you would) prefer|your call|up to you|(?:pick|choose) (?:one|between|from)|for you to (?:decide|pick|choose))\b/i;
|
|
247
|
+
const OPTION_LABEL = /^(?:[-*•+]\s+|\d{1,2}[.)]\s+)?\**(?:option|choice|approach|alternative)\s+(?:[a-d1-9]|one|two|three)\b/i;
|
|
248
|
+
/** True when an agent message ends on a list of options or decisions (problems.ends-on-options). */
|
|
249
|
+
export function endsOnOptions(text) {
|
|
250
|
+
const lines = String(text ?? '').replace(/[’‘]/g, "'").split(/\r?\n/).filter((l) => l.trim());
|
|
251
|
+
let end = lines.length;
|
|
252
|
+
if (end >= 2 && !LIST_ITEM.test(lines[end - 1].trim()) && LIST_ITEM.test(lines[end - 2].trim()) && lines[end - 1].trim().length <= 200) end--;
|
|
253
|
+
let i = end;
|
|
254
|
+
let items = 0;
|
|
255
|
+
let labelled = 0;
|
|
256
|
+
while (i > 0) {
|
|
257
|
+
const l = lines[i - 1];
|
|
258
|
+
if (LIST_ITEM.test(l.trim())) {
|
|
259
|
+
items++;
|
|
260
|
+
if (OPTION_LABEL.test(l.trim())) labelled++;
|
|
261
|
+
} else if (!(/^\s{2,}\S/.test(l) && i < end)) break;
|
|
262
|
+
i--;
|
|
263
|
+
}
|
|
264
|
+
if (items < 2) return false;
|
|
265
|
+
return labelled >= 2 || OPTIONS_LEAD.test(lines.slice(Math.max(0, i - 2), i).join(' '));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// problems.names-blocker. Phrases that say there is no blocker come out first.
|
|
269
|
+
const NOT_A_BLOCKER = /\b(?:non-?blocking|not blocking|nothing(?: is| was)? blocking|none (?:of (?:these|them|this|those) )?(?:is |are )?blocking|no blockers?|nothing blocks|(?:no|zero|0) (?:errors?|failures?|failed|failing)|without (?:any )?(?:errors?|failures?))\b/gi;
|
|
270
|
+
const BLOCKER = new RegExp(
|
|
271
|
+
[
|
|
272
|
+
'\\bblock(?:ed|er|ers|ing)\\b',
|
|
273
|
+
'\\bstuck\\b',
|
|
274
|
+
'\\bwait(?:ing|s)? (?:on|for) (?:ci\\b|the (?:build|job|run|deploy|review|pipeline|checks?|tests?)|(?:a |the )?(?:background|sub-?agent|agent|task))',
|
|
275
|
+
"\\b(?:need|needs|needed|require|requires|required|waiting (?:on|for))\\s+(?:you to (?:provide|give|send|share|add|set|create|log|sign|run|install|enable|grant|paste|tell)|your (?:input|answers?|decision|choice|credentials?|access|keys?|password|token|login|details|help|account|info(?:rmation)?|data|files?)|(?:an? |the )?(?:api key|credentials?|password|token|login|access))\\b",
|
|
276
|
+
"\\b(?:can(?:no|')t|could(?:n't| not)|unable to|not able to|won't be able)\\b",
|
|
277
|
+
'\\b(?:errors?|errored|fail(?:s|ed|ing|ures?)?|exceptions?|crash(?:ed|es|ing)?|broken)\\b',
|
|
278
|
+
'\\b(?:denied|denial|forbidden|unauthori[sz]ed|permissions?|rejected|refused|declined)\\b',
|
|
279
|
+
"\\b(?:missing|no access|lacks?|don't have|do not have|doesn't have)\\b",
|
|
280
|
+
'\\b(?:credentials?|passwords?|api keys?|access tokens?|auth tokens?|log ?in|sign ?in|2fa|mfa|captcha|one-time code)\\b',
|
|
281
|
+
'\\b(?:rate.?limit(?:ed)?|quota|usage limit)',
|
|
282
|
+
'\\bconflicts?\\b',
|
|
283
|
+
"\\bonly you\\b|\\byou(?:'ll| will)? (?:need|have) to\\b|\\bon your (?:end|side)\\b|\\bmanual(?:ly)?\\b|\\bby hand\\b",
|
|
284
|
+
'\\bpending (?:your|review|approval)\\b',
|
|
285
|
+
].join('|'),
|
|
286
|
+
'i',
|
|
287
|
+
);
|
|
288
|
+
/** True when an agent message names a blocker anywhere in it (problems.names-blocker). */
|
|
289
|
+
export const namesBlocker = (text) => BLOCKER.test(String(text ?? '').replace(/[’‘]/g, "'").replace(NOT_A_BLOCKER, ' '));
|
|
290
|
+
|
|
291
|
+
const GO_AHEAD = /^(?:(?:yes|yep|yeah|yup|sure|ok|okay|go|go ahead|go for it|proceed|do it|please do|please|continue|carry on|keep going|lgtm|sounds good|ship it|approved?)(?:[\s,.!]+|$))+$/i;
|
|
292
|
+
/** True when a reply is only a go-ahead, with nothing else in it (problems.bare-go-ahead). */
|
|
293
|
+
export function bareGoAhead(text) {
|
|
294
|
+
const s = String(text ?? '').trim();
|
|
295
|
+
return s.length > 0 && s.length <= 60 && GO_AHEAD.test(s);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export const QUESTION_PROMPT_MAX = 400;
|
|
299
|
+
const QUESTION_START = /^(?:why|what|what's|how|where|which|who|when|is|are|does|do|did|can|could|should|would|was|were|has|have|isn't|aren't|doesn't|didn't)\b/i;
|
|
300
|
+
const REQUEST_WORDS = /\b(?:fix|add|implement|build|change|update|make|create|write|remove|delete|rename|refactor|run|deploy|commit|push|merge|ship|publish|install|set ?up|go ahead|do it|proceed|please|can you|could you|would you|will you|let'?s|i want|i'?d like|i need|need you|try|draft|prepare|open|send|post|apply|edit|move|replace|convert|generate|clean|start|continue|resume|redo|revert|undo|keep|use|switch|finish|complete|handle|address|investigate)\b/i;
|
|
301
|
+
/** Booleans for one prompt (problems.question-only-prompt). */
|
|
302
|
+
export function classifyPrompt(text) {
|
|
303
|
+
const s = String(text ?? '').trim();
|
|
304
|
+
const mentionsTests = /\btests?\b|\bspecs?\b|\bassert/i.test(s);
|
|
305
|
+
if (!s || s.length > QUESTION_PROMPT_MAX || /^[<\/]/.test(s)) return { pureQuestion: false, mentionsTests };
|
|
306
|
+
return { pureQuestion: QUESTION_START.test(s) && /\?\s*$/.test(s) && !REQUEST_WORDS.test(s), mentionsTests };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---- shell commands -----------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
const CHECK_CMD = [
|
|
312
|
+
['build or check script', /^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|build|lint|check|typecheck|type-check|tsc|verify|validate|ci|e2e|smoke)\b/],
|
|
313
|
+
['type check or linter', /^(?:npx\s+)?(?:tsc|eslint|biome|ruff|mypy|pyright|flake8|pylint|shellcheck)\b|^cargo\s+clippy\b|^go\s+vet\b|^(?:npx\s+)?prettier\s+--check\b|^black\s+--check\b/],
|
|
314
|
+
['build', /^(?:cargo\s+(?:build|check)|go\s+build|make|dotnet\s+build|mvn|gradle|\.\/gradlew)\b/],
|
|
315
|
+
['run of code', /^(?:node|deno|bun|tsx|ts-node|python3?|py|ruby|php|go\s+run|cargo\s+run|dotnet\s+run|java)\b|^(?:bash|sh|zsh|pwsh|powershell)(?:\.exe)?\s+(?:-\w+\s+)*\S+\.(?:sh|ps1)\b|^\.{1,2}[\\/]\S+/],
|
|
316
|
+
['request to a server', /^(?:curl|wget|Invoke-WebRequest|iwr|Invoke-RestMethod|irm)\b/i],
|
|
317
|
+
];
|
|
318
|
+
/** The kind of check a shell command runs, or null (problems.check-step). */
|
|
319
|
+
export function checkClass(cmd) {
|
|
320
|
+
if (typeof cmd !== 'string' || !cmd.trim()) return null;
|
|
321
|
+
if (testRunner(cmd)) return 'test';
|
|
322
|
+
for (const c of simpleCommands(cmd)) for (const [name, re] of CHECK_CMD) if (re.test(c)) return name;
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
export const BUILD_CHECKS = new Set(['build or check script', 'type check or linter', 'build']);
|
|
326
|
+
|
|
327
|
+
const READ_ONLY_CMD = /^(?:sleep|Start-Sleep|timeout(?:\.exe)?\s+\/t|git(?:\s+-[Cc]\s+\S+)*\s+(?:status|log|diff|show|branch|rev-parse|remote|ls-files|blame|describe)|gh\s+(?:pr\s+(?:checks|view|status|list|diff)|run\s+(?:view|list|watch)|api|issue\s+(?:view|list)|repo\s+view|auth\s+status)|curl|wget|Invoke-WebRequest|iwr|Invoke-RestMethod|irm|Test-NetConnection|ps|Get-Process|tasklist|pgrep|netstat|Get-NetTCPConnection|Get-Job|Receive-Job|ls|dir|Get-ChildItem|gci|pwd|Get-Location|which|where|Get-Command|echo|Write-Output|Write-Host|Test-Path|wc|find|rg|grep|Select-String|tail|Get-Content|gc|cat|head|type|sed\s+-n|nl|less|more|stat|Get-Item|du|df|date|Get-Date|whoami|hostname|env|printenv|node\s+--version|npm\s+(?:ls|view|--version))\b/i;
|
|
328
|
+
const SHAPING_CMD = /^(?:cd|Set-Location|Select-Object|select|sort|uniq|cut|awk|jq|Out-String|Format-\w+|ft|fl|Where-Object|ConvertFrom-Json|ConvertTo-Json|Measure-Object|true|exit)\b/i;
|
|
329
|
+
/** True when every part of a shell line only reads state (status, listing, printing a file). */
|
|
330
|
+
export function readOnlyShell(cmd) {
|
|
331
|
+
const parts = simpleCommands(cmd);
|
|
332
|
+
return parts.length > 0 && parts.every((p) => READ_ONLY_CMD.test(p) || SHAPING_CMD.test(p));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---- shell file reads (problems.shell-read) ------------------------------------------------
|
|
336
|
+
|
|
337
|
+
/** The words of one command line with their quotes taken off, or null when anything in it
|
|
338
|
+
* isn't a plain word: a separator, a redirect, a substitution, a variable, a glob or a
|
|
339
|
+
* backslash escape outside a Windows path. `|` comes back as its own word. */
|
|
340
|
+
function plainWords(line) {
|
|
341
|
+
const words = [];
|
|
342
|
+
let cur = null;
|
|
343
|
+
for (let i = 0; i < line.length; i++) {
|
|
344
|
+
const ch = line[i];
|
|
345
|
+
if (ch === "'" || ch === '"') {
|
|
346
|
+
const end = line.indexOf(ch, i + 1);
|
|
347
|
+
if (end < 0) return null;
|
|
348
|
+
const body = line.slice(i + 1, end);
|
|
349
|
+
if (ch === '"' && /[$`\\]/.test(body.replace(/\\(?=[^"$`\\\n])/g, ''))) return null;
|
|
350
|
+
cur = (cur ?? '') + body;
|
|
351
|
+
i = end;
|
|
352
|
+
} else if (/\s/.test(ch)) {
|
|
353
|
+
if (cur != null) words.push(cur);
|
|
354
|
+
cur = null;
|
|
355
|
+
} else if (ch === '|') {
|
|
356
|
+
if (cur != null) words.push(cur);
|
|
357
|
+
cur = null;
|
|
358
|
+
if (line[i + 1] === '|') return null;
|
|
359
|
+
words.push({ pipe: true });
|
|
360
|
+
} else if (/[;&<>$`(){}*?!#\n\r]/.test(ch)) return null;
|
|
361
|
+
else cur = (cur ?? '') + ch;
|
|
362
|
+
}
|
|
363
|
+
if (cur != null) words.push(cur);
|
|
364
|
+
return words;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const COUNT = /^\d{1,9}$/;
|
|
368
|
+
const lines = (from, to) => `${from}-${to}`;
|
|
369
|
+
/** A path word a read can name: not empty, not an option, no home or glob shorthand. */
|
|
370
|
+
const pathWord = (w) => typeof w === 'string' && w !== '' && w !== '-' && !w.startsWith('-') && !/^~|[*?]/.test(w);
|
|
371
|
+
|
|
372
|
+
function catRead(args) {
|
|
373
|
+
const files = args.filter((a) => !(a === '-n' || a === '--number'));
|
|
374
|
+
if (files.length !== 1 || !pathWord(files[0])) return null;
|
|
375
|
+
return { file: files[0], range: '' };
|
|
376
|
+
}
|
|
377
|
+
function headTailRead(args, tail) {
|
|
378
|
+
let n = 10;
|
|
379
|
+
let file = null;
|
|
380
|
+
for (let i = 0; i < args.length; i++) {
|
|
381
|
+
const a = args[i];
|
|
382
|
+
let m;
|
|
383
|
+
if (a === '-n' && COUNT.test(args[i + 1] ?? '')) n = Number(args[++i]);
|
|
384
|
+
else if ((m = /^(?:-n|--lines=)(\d{1,9})$/.exec(a)) || (m = /^-(\d{1,9})$/.exec(a))) n = Number(m[1]);
|
|
385
|
+
else if (file == null && pathWord(a)) file = a;
|
|
386
|
+
else return null;
|
|
387
|
+
}
|
|
388
|
+
if (file == null || n < 1) return null;
|
|
389
|
+
return { file, range: tail ? `last:${n}` : lines(1, n) };
|
|
390
|
+
}
|
|
391
|
+
function sedRead(args) {
|
|
392
|
+
if (args.length !== 3 || args[0] !== '-n' || !pathWord(args[2])) return null;
|
|
393
|
+
const m = /^(\d{1,9})(?:,(\d{1,9}|\$))?p$/.exec(args[1]);
|
|
394
|
+
if (!m) return null;
|
|
395
|
+
const from = Number(m[1]);
|
|
396
|
+
const to = m[2] == null ? from : m[2] === '$' ? 'end' : Number(m[2]);
|
|
397
|
+
if (from < 1 || (to !== 'end' && to < from)) return null;
|
|
398
|
+
return { file: args[2], range: from === 1 && to === 'end' ? '' : lines(from, to) };
|
|
399
|
+
}
|
|
400
|
+
const PS_VALUE = { path: 'path', literalpath: 'path', lp: 'path', pspath: 'path', totalcount: 'first', head: 'first', first: 'first', tail: 'last', last: 'last', encoding: 'encoding' };
|
|
401
|
+
function getContentRead(args) {
|
|
402
|
+
const seen = {};
|
|
403
|
+
let literal = false;
|
|
404
|
+
for (let i = 0; i < args.length; i++) {
|
|
405
|
+
const a = args[i];
|
|
406
|
+
if (/^-raw$/i.test(a)) {
|
|
407
|
+
if (seen.raw) return null;
|
|
408
|
+
seen.raw = true;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const m = /^-([A-Za-z]+)(?::(.+))?$/.exec(a);
|
|
412
|
+
const key = m ? PS_VALUE[m[1].toLowerCase()] : 'path';
|
|
413
|
+
if (!key) return null;
|
|
414
|
+
const value = m ? m[2] ?? args[++i] : a;
|
|
415
|
+
if (value == null || seen[key] != null) return null;
|
|
416
|
+
if (m && /^(?:literalpath|lp|pspath)$/i.test(m[1])) literal = true;
|
|
417
|
+
seen[key] = value;
|
|
418
|
+
}
|
|
419
|
+
const file = seen.path;
|
|
420
|
+
// Without -LiteralPath, brackets in a path are wildcards. A comma lists several files, and a
|
|
421
|
+
// provider name longer than a drive letter (Env:, Function:, HKLM:) isn't a file at all.
|
|
422
|
+
if (!pathWord(file) || (!literal && /[[\]]/.test(file)) || file.includes(',') || /^[A-Za-z]{2,}:/.test(file)) return null;
|
|
423
|
+
if (seen.first != null && seen.last != null) return null;
|
|
424
|
+
if (seen.first != null) return COUNT.test(seen.first) && Number(seen.first) > 0 ? { file, range: lines(1, Number(seen.first)) } : null;
|
|
425
|
+
if (seen.last != null) return COUNT.test(seen.last) && Number(seen.last) > 0 ? { file, range: `last:${Number(seen.last)}` } : null;
|
|
426
|
+
return { file, range: '', raw: !!seen.raw };
|
|
427
|
+
}
|
|
428
|
+
/** `| Select-Object -Skip S -First F` (or -First, or -Last) after a Get-Content: a line range. */
|
|
429
|
+
function selectRange(read, args) {
|
|
430
|
+
// With -Raw the whole file is one item, so Select-Object can't pick lines from it.
|
|
431
|
+
if (read.range !== '' || read.raw || !args.length || !/^(?:Select-Object|select)$/i.test(args[0])) return null;
|
|
432
|
+
const o = {};
|
|
433
|
+
for (let i = 1; i < args.length; i += 2) {
|
|
434
|
+
const k = /^-(skip|first|last)$/i.exec(args[i])?.[1].toLowerCase();
|
|
435
|
+
if (!k || o[k] != null || !COUNT.test(args[i + 1] ?? '')) return null;
|
|
436
|
+
o[k] = Number(args[i + 1]);
|
|
437
|
+
}
|
|
438
|
+
if (o.last != null) return o.skip == null && o.first == null && o.last > 0 ? { ...read, range: `last:${o.last}` } : null;
|
|
439
|
+
if (o.first == null || o.first < 1) return null;
|
|
440
|
+
const from = (o.skip ?? 0) + 1;
|
|
441
|
+
return { ...read, range: lines(from, from + o.first - 1) };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* shellFileRead(cmd) -> { file, range, whole } | null (problems.shell-read)
|
|
446
|
+
* The file a shell command line reads and prints, when the line is nothing but that read:
|
|
447
|
+
* cat (with -n at most), head or tail with a line count, sed -n with one line range, or
|
|
448
|
+
* PowerShell's Get-Content (or gc) with a path and a line count, optionally piped to
|
|
449
|
+
* Select-Object for a range. One file only. Anything else on the line (a second file, a pipe to
|
|
450
|
+
* another program, a redirect, a separator, a variable, a glob, follow mode, a byte count, an
|
|
451
|
+
* in-place edit) makes it not a read. `range` is '' for the whole file, `A-B` for lines A to B,
|
|
452
|
+
* and `last:N` for the last N lines, so the same lines read two ways compare equal.
|
|
453
|
+
*/
|
|
454
|
+
export function shellFileRead(cmd) {
|
|
455
|
+
if (typeof cmd !== 'string' || cmd.length > 4096) return null;
|
|
456
|
+
const words = plainWords(cmd.trim());
|
|
457
|
+
if (!words?.length) return null;
|
|
458
|
+
const bar = words.findIndex((w) => typeof w !== 'string');
|
|
459
|
+
if (bar >= 0 && words.slice(bar + 1).some((w) => typeof w !== 'string')) return null;
|
|
460
|
+
const first = bar >= 0 ? words.slice(0, bar) : words;
|
|
461
|
+
const rest = bar >= 0 ? words.slice(bar + 1) : null;
|
|
462
|
+
const [prog, ...args] = first;
|
|
463
|
+
const name = cmdName(String(prog ?? ''));
|
|
464
|
+
let read = null;
|
|
465
|
+
if (/^(?:Get-Content|gc)$/i.test(name)) read = getContentRead(args);
|
|
466
|
+
else if (rest) return null;
|
|
467
|
+
else if (name === 'cat') read = catRead(args);
|
|
468
|
+
else if (name === 'head' || name === 'tail') read = headTailRead(args, name === 'tail');
|
|
469
|
+
else if (name === 'sed') read = sedRead(args);
|
|
470
|
+
if (read && rest) read = selectRange(read, rest);
|
|
471
|
+
if (!read) return null;
|
|
472
|
+
const file = read.file.replace(/^(?:\.[\\/])+/, '');
|
|
473
|
+
return file ? { file, range: read.range, whole: read.range === '' } : null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export const TEST_EXCLUSION = /\s--ignore(?:=|\s)|\s--deselect\s|\s-k\s+["']?not\s|--testPathIgnorePatterns|--test-skip-pattern|\s--exclude(?:=|\s)|--passWithNoTests|\|\|\s*(?:true|exit\s+0|:)\s*(?:$|[;&|)])/;
|
|
477
|
+
export const GIT_BASE_CMD = /\bgit\b[^|;&\n]*\s(?:stash|checkout|switch|worktree)\b/;
|
|
478
|
+
|
|
479
|
+
const GIT_COMMIT_RE = /\bgit(?:\s+-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+))*\s+commit\b/;
|
|
480
|
+
const GH_MERGE_RE = /\bgh\s+pr\s+merge\b/;
|
|
481
|
+
// The risky-command shapes are matched one simple command at a time, with quoted text taken out
|
|
482
|
+
// (executedCommands), so a commit message, a search pattern or a pull request body that names one
|
|
483
|
+
// isn't read as running it. A command a shell wrapper runs (bash -c "...", Codex's joined
|
|
484
|
+
// `bash -lc git push -f`, eval) is read as its own command line.
|
|
485
|
+
const GIT_START = '^(?:\\S*[\\\\/])?git(?:\\.exe)?';
|
|
486
|
+
const GIT_SUB = (sub) => new RegExp(GIT_START + '(?:\\s+(?:-[Cc]\\s+\\S+|--[\\w-]+(?:=\\S+)?))*\\s+(?:' + sub + ')(?=\\s|$)');
|
|
487
|
+
const GIT_PUSH = GIT_SUB('push');
|
|
488
|
+
const HOOKED = GIT_SUB('commit|push|merge|am|rebase|cherry-pick|revert');
|
|
489
|
+
const GIT_COMMIT_SUB = GIT_SUB('commit');
|
|
490
|
+
// git reads config keys case-insensitively, so core.hookspath points the hooks away too.
|
|
491
|
+
const HOOKS_KEY = '[cC][oO][rR][eE]\\.[hH][oO][oO][kK][sS][pP][aA][tT][hH]';
|
|
492
|
+
// Windows reads device names in any case too, so nul is NUL.
|
|
493
|
+
const NUL = '[nN][uU][lL]';
|
|
494
|
+
const NO_HOOKS_C = new RegExp(GIT_START + "\\s(?:.*\\s)?-c\\s+" + HOOKS_KEY + "=(?:\\/dev\\/null|" + NUL + "|''|\"\"|\\s|$)");
|
|
495
|
+
const NO_HOOKS_CONFIG = new RegExp("\\s" + HOOKS_KEY + "\\s+(?:\\/dev\\/null|" + NUL + "|''|\"\")(?:\\s|$)");
|
|
496
|
+
const GIT_CONFIG = GIT_SUB('config');
|
|
497
|
+
const GIT_CLEAN = GIT_SUB('clean');
|
|
498
|
+
const GIT_BRANCH = GIT_SUB('branch');
|
|
499
|
+
const HARD_RESET_RE = /\breset\s+--hard\b/;
|
|
500
|
+
/** A command word's name, with its folder and a .exe dropped (/usr/bin/rm, rm.exe). */
|
|
501
|
+
const cmdName = (word) => word.replace(/^.*[\\/]/, '').replace(/\.exe$/i, '');
|
|
502
|
+
// Words in front of the command that only change how it runs: sudo, env, nohup, time, exec,
|
|
503
|
+
// command, builtin or xargs with their options (and an option's value, short or long, for the
|
|
504
|
+
// ones that take one), a variable assignment, or a background &. Read word by word, never by a
|
|
505
|
+
// backtracking pattern. command -v or -V and sudo -l, -v, -V or -K only look up or list, so
|
|
506
|
+
// nothing behind them runs.
|
|
507
|
+
const LEAD_VALUED = {
|
|
508
|
+
sudo: /^(?:-[ugCDhprtTUR]|--(?:user|group|close-from|chdir|host|prompt|role|type|command-timeout|other-user|chroot))$/,
|
|
509
|
+
env: /^(?:-[uSC]|--(?:unset|split-string|chdir))$/,
|
|
510
|
+
exec: /^-a$/,
|
|
511
|
+
xargs: /^(?:-[nILPEdsa]|--(?:max-args|max-lines|max-procs|delimiter|arg-file|max-chars|process-slot-var))$/,
|
|
512
|
+
};
|
|
513
|
+
const LEAD_LOOKUP = { sudo: /^-[lvVK]$/, command: /^-[vV]$/ };
|
|
514
|
+
function stripLead(p) {
|
|
515
|
+
const w = p.split(/\s+/).filter(Boolean);
|
|
516
|
+
let i = 0;
|
|
517
|
+
while (i < w.length) {
|
|
518
|
+
if (w[i] === '&' || /^[A-Za-z_]\w*=/.test(w[i])) {
|
|
519
|
+
i++;
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
const name = cmdName(w[i]);
|
|
523
|
+
if (!/^(?:sudo|env|nohup|time|exec|command|builtin|xargs)$/.test(name)) break;
|
|
524
|
+
i++;
|
|
525
|
+
while (i < w.length && w[i].startsWith('-') && w[i] !== '--') {
|
|
526
|
+
if (LEAD_LOOKUP[name]?.test(w[i])) return '';
|
|
527
|
+
i += LEAD_VALUED[name]?.test(w[i]) ? 2 : 1;
|
|
528
|
+
}
|
|
529
|
+
if (w[i] === '--') i++;
|
|
530
|
+
}
|
|
531
|
+
return w.slice(i).join(' ');
|
|
532
|
+
}
|
|
533
|
+
const WRAPPER = /^(?:\S*[\\/])?(?:(?:bash|sh|zsh|dash|ksh|pwsh|powershell)(?:\.exe)?\s+(?:-[\w-]+\s+)*?-(?:[a-z]*c|command)|eval)\s+/i;
|
|
534
|
+
const SLOT = /\u0001(\d+)\u0001/g;
|
|
535
|
+
|
|
536
|
+
/** Quoted text swapped for numbered slots, its bodies pushed to `bodies`, in one pass (a pattern
|
|
537
|
+
* with alternation backtracks for seconds on a long unclosed quote). Text after an unclosed quote
|
|
538
|
+
* is a shell syntax error, so it's taken out as a body too and never read as run. */
|
|
539
|
+
function slotQuotes(s, bodies) {
|
|
540
|
+
let out = '';
|
|
541
|
+
let i = 0;
|
|
542
|
+
while (i < s.length) {
|
|
543
|
+
const q = s[i];
|
|
544
|
+
if (q !== "'" && q !== '"') {
|
|
545
|
+
out += q;
|
|
546
|
+
i++;
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
let j = i + 1;
|
|
550
|
+
let body = '';
|
|
551
|
+
while (j < s.length && s[j] !== q) {
|
|
552
|
+
if (q === '"' && s[j] === '\\' && j + 1 < s.length) j++;
|
|
553
|
+
body += s[j++];
|
|
554
|
+
}
|
|
555
|
+
out += `\u0001${bodies.push(body) - 1}\u0001`;
|
|
556
|
+
i = j + 1;
|
|
557
|
+
}
|
|
558
|
+
return out;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** The simple commands a shell line runs, with heredoc bodies and quoted text taken out, the
|
|
562
|
+
* leading words stripLead sets aside dropped, and a shell wrapper's command read in its place.
|
|
563
|
+
* It splits the line itself: simpleCommands drops a leading env, time or exec but leaves their
|
|
564
|
+
* options, and stripLead needs the word to read them (env -i git push -f). */
|
|
565
|
+
function executedCommands(cmd, depth = 0) {
|
|
566
|
+
const bodies = [];
|
|
567
|
+
const s = slotQuotes(String(cmd ?? '').replace(HEREDOC_RE, '<<heredoc'), bodies).replace(/`/g, ';');
|
|
568
|
+
const out = [];
|
|
569
|
+
for (const raw of s.split(SIMPLE_SPLIT)) {
|
|
570
|
+
const p = stripLead(raw);
|
|
571
|
+
const w = WRAPPER.exec(p);
|
|
572
|
+
if (w && depth < 3) {
|
|
573
|
+
const rest = p.slice(w[0].length).trim();
|
|
574
|
+
// The body is the first word after -c (a bare -- may come first); what follows it is the
|
|
575
|
+
// body's own arguments or a redirect, never more of the command.
|
|
576
|
+
const slot = /^(?:--\s+)?\u0001(\d+)\u0001(?=\s|$)/.exec(rest);
|
|
577
|
+
out.push(...executedCommands(slot ? bodies[Number(slot[1])] : rest.replace(SLOT, "''"), depth + 1));
|
|
578
|
+
} else if (p) out.push(p.replace(SLOT, "''"));
|
|
579
|
+
}
|
|
580
|
+
return out;
|
|
581
|
+
}
|
|
582
|
+
/** The arguments after a git subcommand, up to a bare `--`; null when p isn't that subcommand. */
|
|
583
|
+
function gitArgs(p, re) {
|
|
584
|
+
const m = re.exec(p);
|
|
585
|
+
if (!m) return null;
|
|
586
|
+
const args = p.slice(m[0].length).trim().split(/\s+/).filter(Boolean);
|
|
587
|
+
const stop = args.indexOf('--');
|
|
588
|
+
return stop < 0 ? args : args.slice(0, stop);
|
|
589
|
+
}
|
|
590
|
+
/** A short-flag group's letters before the first one that takes a value (git commit -nm "x"). */
|
|
591
|
+
const shortFlags = (a, valued) => (/^-[a-zA-Z]+$/.test(a) ? a.slice(1).split(new RegExp(`[${valued}]`))[0] : '');
|
|
592
|
+
/** True when an rm command deletes recursively and by force: -r (or -R, --recursive) and -f
|
|
593
|
+
* (or --force), in any order or flag group, before a bare --. Read word by word. */
|
|
594
|
+
function rmRecursiveForce(p) {
|
|
595
|
+
const w = p.split(/\s+/);
|
|
596
|
+
if (cmdName(w[0]) !== 'rm') return false;
|
|
597
|
+
let r = false;
|
|
598
|
+
let f = false;
|
|
599
|
+
for (const x of w.slice(1)) {
|
|
600
|
+
if (x === '--') break;
|
|
601
|
+
if (x === '--recursive') r = true;
|
|
602
|
+
else if (x === '--force') f = true;
|
|
603
|
+
else if (/^-[a-zA-Z]+$/.test(x)) {
|
|
604
|
+
if (/[rR]/.test(x)) r = true;
|
|
605
|
+
if (/f/.test(x)) f = true;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return r && f;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** A step that committed or merged (problems.commit-step), how that's known; null otherwise. */
|
|
612
|
+
export function commitStep(e, cmd) {
|
|
613
|
+
if (e.kind !== 'action' || e.facts?.category !== 'shell') return null;
|
|
614
|
+
if (e.facts.git?.pr?.action === 'merged') return { evidence: 'recorded', what: 'merged a pull request (the harness recorded the merge)' };
|
|
615
|
+
if (e.facts.git?.commit?.sha) return { evidence: 'recorded', what: 'made a commit (the harness recorded it)' };
|
|
616
|
+
if (e.facts.result !== 'ok') return null;
|
|
617
|
+
if ((e.inferred ?? []).some((x) => x.rule === 'shell.git-commit-output')) return { evidence: 'inferred', rule: 'shell.git-commit-output', what: 'made a commit (its id read from what the command printed)' };
|
|
618
|
+
const c = String(cmd ?? e.facts.command ?? '');
|
|
619
|
+
if (GH_MERGE_RE.test(c)) return { evidence: 'inferred', rule: 'problems.commit-step', what: 'ran gh pr merge' };
|
|
620
|
+
if (GIT_COMMIT_RE.test(c)) return { evidence: 'inferred', rule: 'problems.commit-step', what: 'ran git commit' };
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** What a shell command that ran reads as (problems.risky-command): [{ kind, words, look }]. */
|
|
625
|
+
export function riskyKinds(cmd, revert) {
|
|
626
|
+
const parts = executedCommands(cmd);
|
|
627
|
+
const any = (test) => parts.some(test);
|
|
628
|
+
const out = [];
|
|
629
|
+
// A push: --force, -f in a short-flag group, or a +refspec force; --force-with-lease alone is
|
|
630
|
+
// the guarded kind. A dry run (-n, --dry-run) pushes nothing.
|
|
631
|
+
const pushes = parts.map((p) => gitArgs(p, GIT_PUSH)).filter((a) => a && !a.some((x) => x === '--dry-run' || shortFlags(x, '').includes('n')));
|
|
632
|
+
const plain = pushes.some((a) => a.some((x, i) => x === '--force' || shortFlags(x, '').includes('f') || (i > 0 && /^\+[^\s+]/.test(x))));
|
|
633
|
+
const lease = pushes.some((a) => a.some((x) => /^--force-with-lease(?:=|$)/.test(x)));
|
|
634
|
+
if (plain || lease) out.push({ kind: plain ? 'force-push' : 'force-with-lease', words: plain ? 'a force-push' : 'a force-push with lease', look: plain, pattern: 'destructive-command' });
|
|
635
|
+
const noVerify = (p) => {
|
|
636
|
+
const a = gitArgs(p, HOOKED);
|
|
637
|
+
if (!a) return false;
|
|
638
|
+
// A word after a flag that takes a value (-m -n) is that value, not a flag.
|
|
639
|
+
return a.includes('--no-verify') || (GIT_COMMIT_SUB.test(p) && a.some((x, i) => !/^(?:-[mFCct]|--message|--file|--template|--author|--date)$/.test(a[i - 1] ?? '') && shortFlags(x, 'mFCctS').includes('n')));
|
|
640
|
+
};
|
|
641
|
+
const noHooks = (p) => NO_HOOKS_C.test(p) || (GIT_CONFIG.test(p) && NO_HOOKS_CONFIG.test(p));
|
|
642
|
+
if (any((p) => noVerify(p) || noHooks(p))) out.push({ kind: 'skip-checks', words: 'skipping git hooks', look: true, pattern: 'bypassing-safeguards' });
|
|
643
|
+
const hard = any((p) => HARD_RESET_RE.test(p));
|
|
644
|
+
if (revert) out.push({ kind: hard ? 'hard-reset' : 'revert', words: hard ? 'a hard reset' : 'discarding or reversing work', look: hard, pattern: 'destructive-command' });
|
|
645
|
+
// git clean: -f (in a group before -e, which takes a pattern) or --force, and no dry run; the
|
|
646
|
+
// word after -e or --exclude is a pattern, never a flag.
|
|
647
|
+
const clean = any((p) => {
|
|
648
|
+
const a = gitArgs(p, GIT_CLEAN);
|
|
649
|
+
if (!a) return false;
|
|
650
|
+
const flags = a.filter((x, i) => !/^(?:-e|--exclude)$/.test(a[i - 1] ?? ''));
|
|
651
|
+
return flags.some((x) => x === '--force' || shortFlags(x, 'e').includes('f')) && !flags.some((x) => x === '--dry-run' || shortFlags(x, 'e').includes('n'));
|
|
652
|
+
});
|
|
653
|
+
const branchD = any((p) => (gitArgs(p, GIT_BRANCH) ?? []).some((x) => shortFlags(x, '').includes('D')));
|
|
654
|
+
if (clean || branchD || any(rmRecursiveForce)) {
|
|
655
|
+
out.push({ kind: clean ? 'git-clean' : branchD ? 'branch-delete' : 'recursive-delete', words: clean ? 'git clean' : branchD ? 'force-deleting a branch' : 'a recursive delete', look: clean, pattern: 'destructive-command' });
|
|
656
|
+
}
|
|
657
|
+
return out;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Status-style calls (problems.status-command) and state changes (problems.state-change).
|
|
661
|
+
const STATUS_SHELL = [
|
|
662
|
+
['wait', /^(sleep|Start-Sleep|timeout(\.exe)?\s+\/t)\b/i],
|
|
663
|
+
['git status', /^git(\s+-[Cc]\s+\S+)*\s+status\b/],
|
|
664
|
+
['CI or pull request status', /^gh\s+(pr\s+(checks|view|status)|run\s+(view|list|watch)|api\s+\S*(check-runs|statuses|actions\/runs|\/pulls\/\d+))/],
|
|
665
|
+
['local server or address check', /^(curl|wget|Invoke-WebRequest|iwr|Invoke-RestMethod|irm|Test-NetConnection)\b/i],
|
|
666
|
+
['process check', /^(ps|Get-Process|tasklist|pgrep|Get-NetTCPConnection|netstat|Get-Job|Receive-Job)\b/i],
|
|
667
|
+
['log tail', /^(tail\b|Get-Content\b.*-(Tail|Wait)\b)/i],
|
|
668
|
+
];
|
|
669
|
+
const FILTER_SHELL = /^(head|tail|grep|rg|findstr|Select-String|Select-Object|select|jq|wc|sort|uniq|cut|awk|sed|echo|Write-Output|Write-Host|cd|Set-Location|Out-String|Format-\w+|ft|fl|Where-Object|where|ConvertFrom-Json|ConvertTo-Json|Measure-Object|true|exit|cat)\b/i;
|
|
670
|
+
const STATUS_TOOL = [
|
|
671
|
+
['background job output', /^(TaskOutput|BashOutput)$/],
|
|
672
|
+
['terminal read', /^mcp__.*__read_terminal$/],
|
|
673
|
+
['server log read', /^mcp__.*__preview_logs$/],
|
|
674
|
+
['CI or pull request status', /^mcp__.*__get_status$/],
|
|
675
|
+
];
|
|
676
|
+
const STATE_CHANGE = /\bgit\b[^|;&\n]*\s(push|commit|merge|rebase|pull|cherry-pick)\b|\bgh\s+(pr\s+(create|merge|ready|edit|close|reopen|comment)|workflow\s+run|run\s+rerun|release\s+create)\b|\bnpm\s+(publish|run\s+deploy)\b|\b(vercel|netlify|wrangler|firebase)\b[^|;&\n]*\bdeploy\b/;
|
|
677
|
+
const SPLIT = /\s*(?:&&|\|\||;|\||\r?\n)\s*/;
|
|
678
|
+
|
|
679
|
+
/** The status class of a shell line and the status part a repeat is compared on (output
|
|
680
|
+
* filters and redirections dropped), or null when any part does work. */
|
|
681
|
+
export function statusOfShell(cmd) {
|
|
682
|
+
const segs = String(cmd ?? '').split(SPLIT).map((s) => s.trim()).filter(Boolean);
|
|
683
|
+
let cls = null;
|
|
684
|
+
let part = null;
|
|
685
|
+
for (const seg of segs) {
|
|
686
|
+
const hit = STATUS_SHELL.find(([, re]) => re.test(seg));
|
|
687
|
+
if (hit) {
|
|
688
|
+
if (!cls || (cls === 'wait' && hit[0] !== 'wait')) {
|
|
689
|
+
cls = hit[0];
|
|
690
|
+
part = hit[0] === 'wait' ? 'wait' : seg.replace(/\s*\d?>&?\d?\s*\S*$/, '').replace(/\s+/g, ' ');
|
|
691
|
+
}
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
if (FILTER_SHELL.test(seg)) continue;
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
return cls ? { cls, part } : null;
|
|
698
|
+
}
|
|
699
|
+
/** The status class of a tool call by its name, or null. */
|
|
700
|
+
export const statusOfTool = (name) => STATUS_TOOL.find(([, re]) => re.test(String(name ?? '')))?.[0] ?? null;
|
|
701
|
+
export const isStateChange = (cmd) => STATE_CHANGE.test(String(cmd ?? ''));
|
|
702
|
+
|
|
703
|
+
// ---- files and edits --------------------------------------------------------------------
|
|
704
|
+
|
|
705
|
+
export const TEST_FILE_RE = /(?:^|\/)(?:tests?|__tests__|specs?)\/|\.(?:test|spec)\.[A-Za-z0-9]+$|(?:^|\/)test_[^/]*\.py$|_test\.(?:py|go)$|(?:^|\/)conftest\.py$/i;
|
|
706
|
+
export const TEST_CONFIG_RE = /(?:^|\/)(?:jest|vitest|playwright|karma|cypress)\.config\.[A-Za-z]+$|(?:^|\/)(?:pytest\.ini|tox\.ini|conftest\.py|\.mocharc(?:\.[A-Za-z]+)?|setupTests\.[A-Za-z]+)$/i;
|
|
707
|
+
const DOC_FILE = /\.(?:md|mdx|markdown|txt|rst|adoc|csv|tsv|svg|png|jpe?g|gif|webp|ico|pdf|log)$|(?:^|\/)(?:LICENSE|NOTICE|AUTHORS|CHANGELOG)[^/]*$/i;
|
|
708
|
+
const CONFIG_FILE = /\.(?:json|jsonc|ya?ml|toml|ini|cfg|conf|env|properties|lock)$|(?:^|\/)\.[^/]+$/i;
|
|
709
|
+
/** doc, config, test or code, from a normalized path (problems.runnable-file, problems.test-file). */
|
|
710
|
+
export function fileKind(norm) {
|
|
711
|
+
if (TEST_FILE_RE.test(norm)) return 'test';
|
|
712
|
+
if (DOC_FILE.test(norm)) return 'doc';
|
|
713
|
+
if (CONFIG_FILE.test(norm)) return 'config';
|
|
714
|
+
return 'code';
|
|
715
|
+
}
|
|
716
|
+
export const PLAN_FILE_RE = /\/\.claude\/plans\/[^/]+\.md$/i;
|
|
717
|
+
|
|
718
|
+
/** Under a folder, outside any .claude folder (problems.in-repo-edit). */
|
|
719
|
+
export function underFolder(norm, cwd) {
|
|
720
|
+
if (!cwd) return false;
|
|
721
|
+
const base = `${normPath(cwd).toLowerCase()}/`;
|
|
722
|
+
const l = norm.toLowerCase();
|
|
723
|
+
return l.startsWith(base) && !/(?:^|\/)\.claude\//.test(l.slice(base.length));
|
|
724
|
+
}
|
|
725
|
+
/** A relative path joined under a folder. */
|
|
726
|
+
export function absoluteIn(file, cwd) {
|
|
727
|
+
if (/^([A-Za-z]:[\\/]|[\\/]|~[\\/])/.test(file) || !cwd) return file;
|
|
728
|
+
return `${String(cwd).replace(/[\\/]+$/, '')}/${file}`;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const ASSERT_LINE = /\bassert(?:\.\w+)*\s*\(|^\s*assert\s+(?!from\b|\{|\*)\S|\bexpect\s*\(|\.should\.|\bt\.(?:is|not|deepEqual|notDeepEqual|equal|notEqual|ok|notOk|true|false|truthy|falsy|throws|notThrows|rejects|match|regex|snapshot|strictEqual|deepStrictEqual)\s*\(|\bself\.assert\w*\s*\(|\bassert_eq!|\bassert!\s*\(/;
|
|
732
|
+
const TEST_DEF_LINE = /^\s*(?:it|test|describe|suite|context)(?:\.each\([^)]*\))?\s*\(|^\s*(?:async\s+)?def\s+test_?\w*\s*\(|^\s*func\s+Test\w*\s*\(|^\s*#\[test\]|^\s*@Test\b/;
|
|
733
|
+
const SKIP_MARK = /\b(?:it|test|describe|suite|context)\.(?:skip|only|todo)\b|\b(?:xit|xtest|xdescribe)\s*\(|@pytest\.mark\.(?:skip|skipif|xfail)\b|\bpytest\.(?:skip|xfail)\s*\(|@unittest\.skip|\.skipTest\s*\(|@(?:Ignore|Disabled)\b|\{\s*(?:skip|only|todo)\s*:\s*(?:true|['"])|\bt\.(?:skip|todo)\s*\(|#\[ignore\]/;
|
|
734
|
+
const EXIT_LINE = /\bprocess\.exit\s*\(\s*0?\s*\)|\bsys\.exit\s*\(\s*0?\s*\)|\bos\._exit\s*\(/;
|
|
735
|
+
const COMMENTED_ASSERT = /^\s*(?:\/\/|#)\s*(?:assert|expect\s*\(|t\.\w+\s*\()/;
|
|
736
|
+
/** Counts over an edit's old and new text in a test file (problems.test-weakening). */
|
|
737
|
+
export function testEditCounts(oldText, newText) {
|
|
738
|
+
const lines = (s) => (typeof s === 'string' && s ? s.split(/\r?\n/) : []);
|
|
739
|
+
const count = (arr, re) => arr.filter((l) => re.test(l)).length;
|
|
740
|
+
const asserts = (arr) => arr.filter((l) => !COMMENTED_ASSERT.test(l) && ASSERT_LINE.test(l)).length;
|
|
741
|
+
const o = lines(oldText);
|
|
742
|
+
const n = lines(newText);
|
|
743
|
+
return {
|
|
744
|
+
assertNet: asserts(o) - asserts(n),
|
|
745
|
+
testsNet: count(o, TEST_DEF_LINE) - count(n, TEST_DEF_LINE),
|
|
746
|
+
skipAdded: Math.max(0, count(n, SKIP_MARK) - count(o, SKIP_MARK)),
|
|
747
|
+
exitAdded: Math.max(0, count(n, EXIT_LINE) - count(o, EXIT_LINE)),
|
|
748
|
+
commentedAdded: Math.max(0, count(n, COMMENTED_ASSERT) - count(o, COMMENTED_ASSERT)),
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** A Codex patch's files, each with its operation and its added and removed lines. */
|
|
753
|
+
export function parsePatch(text) {
|
|
754
|
+
const files = [];
|
|
755
|
+
let cur = null;
|
|
756
|
+
for (const line of String(text ?? '').split(/\r?\n/)) {
|
|
757
|
+
const m = line.match(/^\*\*\* (Update|Add|Delete) File: (.+?)\s*$/);
|
|
758
|
+
if (m) {
|
|
759
|
+
cur = { op: m[1].toLowerCase(), path: m[2], add: [], del: [] };
|
|
760
|
+
files.push(cur);
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (line.startsWith('***')) {
|
|
764
|
+
if (/^\*\*\* End Patch/.test(line)) cur = null;
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
if (!cur) continue;
|
|
768
|
+
if (line.startsWith('+')) cur.add.push(line.slice(1));
|
|
769
|
+
else if (line.startsWith('-')) cur.del.push(line.slice(1));
|
|
770
|
+
}
|
|
771
|
+
return files;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ---- tool errors ------------------------------------------------------------------------
|
|
775
|
+
|
|
776
|
+
const EDIT_WHY = [
|
|
777
|
+
['text to replace not found', /String to replace not found|old_string not found|could not find the (?:text|string)/i],
|
|
778
|
+
['text matched more than once', /Found \d+ matches|matches of the string to replace|replace_all/i],
|
|
779
|
+
['file not read first', /has not been read yet|Read it first|must read/i],
|
|
780
|
+
['file changed since it was read', /modified since (?:it was )?read|has been modified/i],
|
|
781
|
+
['edit made no change', /No changes to make|exactly the same/i],
|
|
782
|
+
['patch did not apply', /verification failed|patch|hunk/i],
|
|
783
|
+
];
|
|
784
|
+
// "command not found" is tried first: the plain "not found" words would match it too.
|
|
785
|
+
const OTHER_WHY = [
|
|
786
|
+
['command not found', /command not found|is not recognized as/i],
|
|
787
|
+
['not found', /no such file|enoent|does not exist|cannot find (?:the )?(?:path|file|module)|not found/i],
|
|
788
|
+
['unknown option or bad argument', /unknown (?:option|flag|argument|command)|unrecognized (?:option|argument)|invalid (?:option|argument|value)|usage:/i],
|
|
789
|
+
['permission denied', /permission denied|eacces|access is denied|eperm|operation not permitted/i],
|
|
790
|
+
['timed out', /timed? ?out\b|timeout/i],
|
|
791
|
+
['network error', /econnrefused|econnreset|fetch failed|getaddrinfo|socket hang up|network/i],
|
|
792
|
+
['syntax error', /syntaxerror|parse error|unexpected token|invalid syntax/i],
|
|
793
|
+
];
|
|
794
|
+
/** An error's class, from fixed words (problems.error-signature). */
|
|
795
|
+
export function errorClass(cat, body, exit) {
|
|
796
|
+
const b = String(body ?? '');
|
|
797
|
+
if (cat === 'edit') for (const [w, re] of EDIT_WHY) if (re.test(b)) return w;
|
|
798
|
+
for (const [w, re] of OTHER_WHY) if (re.test(b)) return w;
|
|
799
|
+
if (Number.isFinite(exit) && exit !== 0) return 'non-zero exit';
|
|
800
|
+
return 'other error';
|
|
801
|
+
}
|
|
802
|
+
/** The first error line with paths, quoted text and numbers taken out: a comparison key that
|
|
803
|
+
* is fingerprinted and never kept. */
|
|
804
|
+
export function errorLine(body) {
|
|
805
|
+
const lines = String(body ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
806
|
+
const line = lines.find((l) => /error|fail|denied|not found|invalid|unknown|cannot|can't|unable/i.test(l)) ?? lines[0] ?? '';
|
|
807
|
+
return line.toLowerCase().replace(/[a-z]:[\\/][^\s'"`]*|(?:^|\s)\/[^\s'"`]+/gi, ' p ').replace(/(["'`])(?:(?!\1).)*\1/g, 'q').replace(/0x[0-9a-f]+|\d+(?:\.\d+)*/g, '#').replace(/\s+/g, ' ').slice(0, 160);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ---- sub-agent briefs -------------------------------------------------------------------
|
|
811
|
+
|
|
812
|
+
/** Distinct words of a brief (problems.near-identical-brief). */
|
|
813
|
+
export function briefWords(text) {
|
|
814
|
+
return new Set(String(text ?? '').toLowerCase().match(/[a-z0-9][a-z0-9'-]{2,}/g) ?? []);
|
|
815
|
+
}
|
|
816
|
+
/** Shared words over all words of two briefs. */
|
|
817
|
+
export function similarity(a, b) {
|
|
818
|
+
if (!a?.size || !b?.size) return 0;
|
|
819
|
+
let both = 0;
|
|
820
|
+
for (const w of a) if (b.has(w)) both++;
|
|
821
|
+
return both / (a.size + b.size - both);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ---- secret shapes ------------------------------------------------------------------------
|
|
825
|
+
|
|
826
|
+
const KEY_SHAPES = REDACTION_SOURCES.api.map((x) => new RegExp(x, 'g'));
|
|
827
|
+
const PRIVATE_KEY_RE = /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----/g;
|
|
828
|
+
const URL_PASSWORD_RE = /(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s:/@"'<>]+:([^\s/@"'<>]{6,})@/g;
|
|
829
|
+
const BEARER_RE = /\b(?:bearer|basic)[ \t]+([A-Za-z0-9._~+/=-]{20,})/gi;
|
|
830
|
+
const FIELD_RE = /(["']?)([A-Za-z_][A-Za-z0-9_.-]{1,63})\1\s*(?::=|=|:)\s*["']?([A-Za-z0-9_+/=.~-]{16,})/g;
|
|
831
|
+
/** The next field inside a matched value (the value's characters allow key=), read in place; the
|
|
832
|
+
* name may follow other value characters, as in --api-key= or config/TOKEN=. */
|
|
833
|
+
const INNER_FIELD = /[A-Za-z0-9_+/=.~-]*?([A-Za-z_][A-Za-z0-9_.-]{1,63})=/y;
|
|
834
|
+
const FIELD_AT = new RegExp(FIELD_RE.source, 'y');
|
|
835
|
+
const MAX_LABELS = 8;
|
|
836
|
+
// A field's name counts when the redactor would hide its value for that name (keyIsSecret), so
|
|
837
|
+
// this check and the redactor never disagree about which names are sensitive.
|
|
838
|
+
const secretKey = (key) => keyIsSecret(key.replace(/\./g, '_'));
|
|
839
|
+
const PLACEHOLDER_VALUE = /^(?:\$\{.*|\$[A-Za-z_]|process\.env|env\.|<.*>|\*+|x{6,}|\[redacted)/i;
|
|
840
|
+
const keyShaped = (v) => KEY_SHAPES.some((re) => new RegExp(re.source).test(v));
|
|
841
|
+
/** A value that reads as a credential: letters and digits mixed, not a run of words. */
|
|
842
|
+
function randomLooking(v) {
|
|
843
|
+
if (v.length < 16 || PLACEHOLDER_VALUE.test(v) || !/[A-Za-z]/.test(v) || !/[0-9]/.test(v)) return false;
|
|
844
|
+
const parts = v.split(/[-_./]+/).filter(Boolean);
|
|
845
|
+
const wordy = parts.length > 1 && parts.every((p) => /^[A-Za-z][a-z]*\d{0,4}$/.test(p) || /^\d{1,4}$/.test(p));
|
|
846
|
+
return !wordy;
|
|
847
|
+
}
|
|
848
|
+
function secretsInText(str, into) {
|
|
849
|
+
for (const re of KEY_SHAPES) for (const _ of str.matchAll(re)) into.keyShape = (into.keyShape ?? 0) + 1;
|
|
850
|
+
for (const _ of str.matchAll(PRIVATE_KEY_RE)) into.privateKey = (into.privateKey ?? 0) + 1;
|
|
851
|
+
for (const m of str.matchAll(URL_PASSWORD_RE)) if (!PLACEHOLDER_VALUE.test(m[1]) && !/^(?:password|pass|secret)$/i.test(m[1])) into.urlPassword = (into.urlPassword ?? 0) + 1;
|
|
852
|
+
for (const m of str.matchAll(BEARER_RE)) if (randomLooking(m[1])) into.bearer = (into.bearer ?? 0) + 1;
|
|
853
|
+
// A label in front ("one: DOCS_TOKEN=...", "label=PASSWORD=...") is part of the matched value,
|
|
854
|
+
// so the fields inside it are read too, up to MAX_LABELS deep, in place: never a rescan, which
|
|
855
|
+
// turns quadratic on a long run of a=b.
|
|
856
|
+
const fields = new RegExp(FIELD_RE.source, 'g');
|
|
857
|
+
for (let m; (m = fields.exec(str)); ) {
|
|
858
|
+
const end = m.index + m[0].length;
|
|
859
|
+
let key = m[2];
|
|
860
|
+
let at = end - m[3].length;
|
|
861
|
+
let cutShort = false;
|
|
862
|
+
for (let n = 0; n <= MAX_LABELS; n++) {
|
|
863
|
+
if (secretKey(key)) {
|
|
864
|
+
const v = str.slice(at, end);
|
|
865
|
+
if (randomLooking(v) && !keyShaped(v)) {
|
|
866
|
+
into.field = (into.field ?? 0) + 1;
|
|
867
|
+
break;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
INNER_FIELD.lastIndex = at;
|
|
871
|
+
const inner = INNER_FIELD.exec(str);
|
|
872
|
+
if (!inner || INNER_FIELD.lastIndex >= end) {
|
|
873
|
+
cutShort = true;
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
key = inner[1];
|
|
877
|
+
at = INNER_FIELD.lastIndex;
|
|
878
|
+
}
|
|
879
|
+
// A long name the label took as its value ("one: GITHUB_ACCESS_TOKEN = ...") ends the value,
|
|
880
|
+
// and its own quote, space, := or : sits past it. Read that name again as a field of its own,
|
|
881
|
+
// from where it starts (its opening quote included). The name is at most 64 characters at the
|
|
882
|
+
// value's end, so this stays one pass.
|
|
883
|
+
if (cutShort) {
|
|
884
|
+
let from = str[end - 1] === '=' ? end - 1 : end;
|
|
885
|
+
const stop = Math.max(at, from - 64);
|
|
886
|
+
while (from > stop && /[A-Za-z0-9_.-]/.test(str[from - 1])) from--;
|
|
887
|
+
while (from < end && !/[A-Za-z_]/.test(str[from])) from++;
|
|
888
|
+
for (const start of /["']/.test(str[from - 1]) ? [from - 1, from] : [from]) {
|
|
889
|
+
if (start >= end) break;
|
|
890
|
+
FIELD_AT.lastIndex = start;
|
|
891
|
+
if (FIELD_AT.test(str)) {
|
|
892
|
+
fields.lastIndex = start;
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
/** Counts by kind of secret-shaped values in a string or a tool input (problems.secret-shape):
|
|
900
|
+
* { keyShape, privateKey, urlPassword, bearer, field }. Counts only; nothing is kept. */
|
|
901
|
+
export function secretShapes(value, into = {}, key = null, depth = 0) {
|
|
902
|
+
if (depth > 32) return into;
|
|
903
|
+
if (typeof value === 'string') {
|
|
904
|
+
if (key && secretKey(key) && randomLooking(value) && !keyShaped(value)) into.field = (into.field ?? 0) + 1;
|
|
905
|
+
secretsInText(value, into);
|
|
906
|
+
} else if (Array.isArray(value)) for (const v of value) secretShapes(v, into, key, depth + 1);
|
|
907
|
+
else if (value && typeof value === 'object') for (const [k, v] of Object.entries(value)) secretShapes(v, into, k, depth + 1);
|
|
908
|
+
return into;
|
|
909
|
+
}
|
|
910
|
+
export const SECRET_KIND_WORDS = Object.freeze({ keyShape: ['known key or token shape', 'known key or token shapes'], privateKey: ['private-key block', 'private-key blocks'], urlPassword: ['password in a web address', 'passwords in web addresses'], bearer: ['Bearer or Basic credential', 'Bearer or Basic credentials'], field: ['credential-named field with a random-looking value', 'credential-named fields with random-looking values'] });
|