intent-record 1.1.0 → 1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b2abf7a93c2bbefd6b31f0ff8ee7f08def82e721b1166885b8701f1f003162e7
4
- data.tar.gz: f5754926ff4e9b0ff7d339423196487610e816bdd164f3fc96344fb9423ef83c
3
+ metadata.gz: '09016b9ff684f0f9137aa00a6848f68b6018cd870495a73d9ebfe11dd4706d8f'
4
+ data.tar.gz: 93d53144d6ea2bfe0c997d6242ed4e0e34843392998562824196d44494a0c569
5
5
  SHA512:
6
- metadata.gz: 844a434054ae004e8bc75bb84b20e6d9a301842e860f66f715add6ac14926fa9c378d83a79d8fccabcdc97e9ff28a4b0c8fbed28194fc90b3f558ff687d6c5f5
7
- data.tar.gz: 38a447b34c8fb3a2f4543c2d1c1e8d5cbb4d71669804cdd499b88ec6dfd1e48439fc26670f9e4e5bba838fa8bc4857a535a0abe6d9a8f59a2ed6008478904708
6
+ metadata.gz: 34aa8df17cbecd1c6eda808dde6dbb54d8c13d6acd5741c62b89cfdfb54843a942f951cd06e7f84935d45486609a90ec3d3696bcd30dfbf9230694d0c2bc9f41
7
+ data.tar.gz: bfccfc5091b3fde1060e638d6e9f3f451daad4d8a984ca144d705bae19133a948f10f555213061510eea2dda252312bc0dd07db87c14dcce5eba39087cdababf
data/CHANGELOG.md CHANGED
@@ -6,6 +6,20 @@ Notable changes, one section per released version. The format follows
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 1.2.0 - 2026-09-20
10
+
11
+ ### Added
12
+
13
+ - `blame`, which answers "why is this line here" for a range of lines. Blame output goes in on stdin, so the tool still never shells out to git and the same command works for Perforce or Mercurial. `--format git-porcelain` reads what `git blame --porcelain` prints, so the common case is one command.
14
+ - Neighbouring lines from one change are answered once, as a span. A change the store has never heard of still gets a span, with no intents, and a line you have edited but not committed is marked `uncommitted`.
15
+ - A search term can name one field: `summary:retry`, `body:retry`, `uri:ACME-42`, `title:flaky`. The name is read in any case. Only those four names count, so a ticket url is still searched as itself.
16
+
17
+ ### Changed
18
+
19
+ - Search answers most relevant first rather than newest first, with a term in a summary counting for more than the same term in a long body. Each result carries the fragment of the body its terms landed in, and the GUI's search page shows that instead of every body in full.
20
+ - A plain search word now also matches the other forms of itself, so `retry` finds a record that said `retried`. A term carrying punctuation is still matched as the literal you typed, so `100%` does not match "100 percent" and `ACME-4` still finds `ACME-42`.
21
+ - Upgrading indexes the records a store already holds, so ranking works on existing history from the first run rather than only on what is recorded afterwards.
22
+
9
23
  ## 1.1.0 - 2026-09-19
10
24
 
11
25
  ### Added
data/README.md CHANGED
@@ -7,6 +7,7 @@ Local records storage for the intent behind individual code changes.
7
7
 
8
8
  A commit message says what changed. The intent record says why: what the agent (or person) was trying to achieve, which ticket or design page asked for it, and which earlier change it builds on. intent-record stores that in a local SQLite database, links it to the commit hash, and answers the questions people and agents ask later.
9
9
 
10
+ - "Why is this line here?" (`git blame --porcelain f.rb | intent-record blame --format git-porcelain`)
10
11
  - "What was this commit for?" (`lookup <hash>`)
11
12
  - "What did we build for this Jira ticket, and why that way?" (`by-source <url-or-key>`)
12
13
  - "Have we touched retry logic before, and what were we thinking?" (`search retry`)
@@ -46,6 +47,9 @@ intent-record lookup 8f3a1c2
46
47
  # Or comes at it from the ticket
47
48
  intent-record by-source ACME-42 --contains
48
49
 
50
+ # Or is staring at one line of a file and wants to know why it is there
51
+ git blame --porcelain lib/fetch.rb | intent-record blame --format git-porcelain
52
+
49
53
  # Or wants to read it in a browser
50
54
  intent-record serve
51
55
  ```
@@ -60,10 +64,11 @@ JSON in on stdin where input is needed, JSON out on stdout, exit code 0 on succe
60
64
  | `attach <intent_id>` | Link more commits, stakeholder references or related intents to an existing record (JSON via stdin) |
61
65
  | `show <intent_id>` | Full intent record with commits, stakeholder links and related intents |
62
66
  | `lookup <commit> [--vcs name]` | All intents recorded against a commit. Accepts a full hash or a unique prefix of at least 4 characters. Ids are unique per system, not across them, so an id recorded in two systems is reported as ambiguous; name one with `--vcs` |
63
- | `search <terms...> [--match all]` | Case-insensitive substring search over summary, body, and linked stakeholder URIs and titles, so a ticket key finds its intents |
67
+ | `search <terms...> [--match all]` | Search over summary, body, and linked stakeholder URIs and titles, most relevant first, so a ticket key finds its intents |
64
68
  | `by-source <uri> [--contains]` | Intents linked to a stakeholder source, plus the distinct commits across them. `--contains` matches a substring such as a ticket key |
65
69
  | `recent [--limit N]` | Newest intents first |
66
70
  | `systems` | Known VCS and stakeholder system names |
71
+ | `blame [--format git-porcelain]` | Intents for the commits a blame output names, answered as spans of lines (JSON via stdin, or `git blame --porcelain` output with `--format git-porcelain`) |
67
72
  | `backfill` | Recover intent records from a history of commit messages (JSON via stdin) |
68
73
  | `serve [--port N]` | Start the web GUI on 127.0.0.1 (default port 4791) |
69
74
 
@@ -89,6 +94,79 @@ Options take either `--name value` or `--name=value`. Unknown options and stray
89
94
 
90
95
  An intent can be recorded before the commit exists and linked with `attach` afterwards. This also covers rebases and squashes, where the same intent ends up on a new hash. One commit can carry several intents and one intent can span several commits.
91
96
 
97
+ ## Searching
98
+
99
+ Results come back most relevant first, not newest first, with a term in a summary
100
+ counting for more than the same term somewhere in a long body. Each result carries a
101
+ `snippet`: the part of the body the terms landed in, or the opening of the body when
102
+ the match was a literal one, so a page of hits is readable without opening any of them.
103
+
104
+ ```bash
105
+ intent-record search retry backoff # either word
106
+ intent-record search retry backoff --match all # both
107
+ intent-record search "retry the fetch" # the words in that order
108
+ ```
109
+
110
+ A plain word also matches the other forms of itself, so `retry` finds a record that
111
+ said `retried`. A term carrying punctuation is matched as the literal you typed:
112
+ `100%` does not match "100 percent", and `ACME-4` still finds `ACME-42` the way a
113
+ substring search does.
114
+
115
+ A term can name one field, for when a common word turns up everywhere:
116
+
117
+ ```bash
118
+ intent-record search summary:retry # summary, body, uri or title
119
+ ```
120
+
121
+ Only those four names count, so a ticket URL is searched as itself rather than read
122
+ as a field called `https`.
123
+
124
+ ## Why is this line here
125
+
126
+ `lookup` answers for a commit, which means finding the commit first. That is three
127
+ steps: run blame, copy the hash, look it up. `blame` does the whole thing.
128
+
129
+ ```bash
130
+ git blame --porcelain lib/fetch.rb | intent-record blame --format git-porcelain
131
+ git blame --porcelain -L 40,60 lib/fetch.rb | intent-record blame --format git-porcelain
132
+ ```
133
+
134
+ The answer is one entry per change rather than one per line, because neighbouring
135
+ lines from the same change collapse into a span:
136
+
137
+ ```json
138
+ {"spans": [
139
+ {"from": 40, "to": 44,
140
+ "asset_version": {"vcs": "git", "external_id": "8f3a1c2d..."},
141
+ "intents": [{"intent_id": "EUt4WMY", "summary": "Retry flaky fetches with backoff", "...": "..."}]},
142
+ {"from": 45, "to": 45,
143
+ "asset_version": {"vcs": "git", "external_id": "b2c4e6f8..."},
144
+ "intents": []},
145
+ {"from": 46, "to": 46,
146
+ "asset_version": {"vcs": "git", "external_id": "0000000000..."},
147
+ "intents": [], "uncommitted": true}
148
+ ]}
149
+ ```
150
+
151
+ A change nothing was recorded against still gets a span, with no intents. A line you
152
+ have edited but not committed is marked `uncommitted`, because there was never
153
+ anything to record against it.
154
+
155
+ The same change appearing twice in a file with someone else's edit between is
156
+ answered as two spans.
157
+
158
+ ### Other version control systems
159
+
160
+ `--format git-porcelain` is a convenience. The contract is JSON, and any tool that
161
+ can say which change a line came from can feed it:
162
+
163
+ ```bash
164
+ echo '{"vcs": "perforce", "lines": [{"line": 40, "external_id": "12345"}]}' | intent-record blame
165
+ ```
166
+
167
+ `vcs` defaults to `git`. Line numbers start at 1, and the same line given twice is
168
+ rejected rather than answered from one of the two changes.
169
+
92
170
  ## Backfilling an existing repo
93
171
 
94
172
  A repo that adopts intent-record already has years of history, and `lookup` and `by-source` answer nothing for any of it. That history is usually exactly what someone needs when they open unfamiliar code. The ticket keys are already in the commit messages, so `backfill` reads them and writes the rows `record` would have written at the time.
@@ -0,0 +1,64 @@
1
+ # A relevance index over the text of an intent record. It decides the order the
2
+ # search answers in, never which records the search answers with: FTS5 tokenises,
3
+ # so it cannot see a substring inside a word and it cannot tell `100%` from `100
4
+ # percent`, both of which the substring search is asked for and tested on.
5
+ #
6
+ # Kept in step by triggers rather than from Ruby, so a writer that never heard of
7
+ # the index cannot leave it stale. `backfill` writes through `record` today, but
8
+ # the guarantee should not depend on that staying true.
9
+ class CreateSearchIndex < ActiveRecord::Migration[8.0]
10
+ TABLE = "intent_search".freeze
11
+ SOURCE = "intent_records".freeze
12
+
13
+ def up
14
+ # An external-content table: the text lives in intent_records and the index
15
+ # stores only the terms, so the store does not carry a second copy of every
16
+ # body.
17
+ execute(<<~SQL)
18
+ CREATE VIRTUAL TABLE #{TABLE} USING fts5(
19
+ summary, body,
20
+ content='#{SOURCE}', content_rowid='id',
21
+ tokenize='porter unicode61'
22
+ )
23
+ SQL
24
+
25
+ create_triggers
26
+ backfill_existing
27
+ end
28
+
29
+ def down
30
+ %w[insert update delete].each { |event| execute("DROP TRIGGER IF EXISTS #{TABLE}_after_#{event}") }
31
+ execute("DROP TABLE IF EXISTS #{TABLE}")
32
+ end
33
+
34
+ private
35
+
36
+ # An external-content table is told about a change rather than reading it: the
37
+ # 'delete' command hands back the old text so FTS5 can retract the terms it
38
+ # indexed, which is why an update deletes before it inserts.
39
+ def create_triggers
40
+ execute(<<~SQL)
41
+ CREATE TRIGGER #{TABLE}_after_insert AFTER INSERT ON #{SOURCE} BEGIN
42
+ INSERT INTO #{TABLE}(rowid, summary, body) VALUES (new.id, new.summary, new.body);
43
+ END
44
+ SQL
45
+ execute(<<~SQL)
46
+ CREATE TRIGGER #{TABLE}_after_delete AFTER DELETE ON #{SOURCE} BEGIN
47
+ INSERT INTO #{TABLE}(#{TABLE}, rowid, summary, body) VALUES('delete', old.id, old.summary, old.body);
48
+ END
49
+ SQL
50
+ execute(<<~SQL)
51
+ CREATE TRIGGER #{TABLE}_after_update AFTER UPDATE ON #{SOURCE} BEGIN
52
+ INSERT INTO #{TABLE}(#{TABLE}, rowid, summary, body) VALUES('delete', old.id, old.summary, old.body);
53
+ INSERT INTO #{TABLE}(rowid, summary, body) VALUES (new.id, new.summary, new.body);
54
+ END
55
+ SQL
56
+ end
57
+
58
+ # A store that already holds records gets them indexed here, so ranking works
59
+ # on the history from the first run after the upgrade rather than only on what
60
+ # is written afterwards.
61
+ def backfill_existing
62
+ execute("INSERT INTO #{TABLE}(rowid, summary, body) SELECT id, summary, body FROM #{SOURCE}")
63
+ end
64
+ end
@@ -0,0 +1,37 @@
1
+ module IntentRecord
2
+ module Blame
3
+ # Reads `git blame --porcelain` into the lines the command takes.
4
+ #
5
+ # The JSON shape is the contract and this is a convenience, so that blaming a
6
+ # file from a git repository is one command rather than one command and a
7
+ # converter. Keeping it a separate reader is what stops the store learning
8
+ # anything about git: it produces the same payload a person could have
9
+ # written by hand, and a second version control system earns a second reader
10
+ # rather than a change here.
11
+ module Porcelain
12
+ # "<sha> <line in the original file> <line in this file> [<how many
13
+ # follow>]". The second number is the one a reader is asking about. Content
14
+ # lines are prefixed with a tab, so a file whose own text looks like a
15
+ # header is never read as one.
16
+ HEADER = /\A(?<id>[0-9a-f]{40}|[0-9a-f]{64}) \d+ (?<line>\d+)(?: \d+)?\z/
17
+
18
+ module_function
19
+
20
+ def lines(text)
21
+ found = text.to_s.lines.filter_map { |line| header(line) }
22
+ raise ValidationError, "No blame headers found. Expected `git blame --porcelain` output." if found.empty?
23
+
24
+ found
25
+ end
26
+
27
+ def header(line)
28
+ match = HEADER.match(line.chomp)
29
+ return nil if match.nil?
30
+
31
+ { "line" => match[:line].to_i, "external_id" => match[:id] }
32
+ end
33
+
34
+ private_class_method :header
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,74 @@
1
+ require_relative "../input_validator"
2
+
3
+ module IntentRecord
4
+ module Blame
5
+ # Reads a blame payload into the spans a reader is answered with.
6
+ #
7
+ # A blame tool answers per line, and a reader asked about a range. Answering
8
+ # per line would bury the useful part in repetition, so neighbouring lines
9
+ # from one change become one span. Neighbouring, not merely equal: the same
10
+ # change either side of someone else's edit is two places in the file, and
11
+ # one span across them would claim a line it does not own.
12
+ #
13
+ # Nothing here reaches the database, so a malformed payload is refused before
14
+ # the store is asked anything.
15
+ class Spans
16
+ Span = Struct.new(:external_id, :from, :to)
17
+
18
+ def initialize(lines)
19
+ @lines = lines
20
+ end
21
+
22
+ def call
23
+ collapse(sorted)
24
+ end
25
+
26
+ private
27
+
28
+ def sorted
29
+ raise ValidationError, "At least one line is required" unless @lines.is_a?(Array) && @lines.any?
30
+
31
+ entries = @lines.map { |line| entry(line) }.sort_by(&:first)
32
+ reject_repeats(entries)
33
+ entries
34
+ end
35
+
36
+ # Sorted, so a repeat is adjacent. The same line attributed to two changes
37
+ # is a contradiction in the payload, and answering it would mean choosing
38
+ # one of them silently.
39
+ def reject_repeats(entries)
40
+ entries.each_cons(2) do |(earlier, _), (later, _)|
41
+ raise ValidationError, "Line #{earlier} is given more than once" if earlier == later
42
+ end
43
+ end
44
+
45
+ def entry(line)
46
+ raise ValidationError, "Each line needs a line and an external_id" unless line.is_a?(Hash)
47
+
48
+ [line_number(line["line"]), InputValidator.required_string!(line, "external_id")]
49
+ end
50
+
51
+ # A string is read in base ten: Integer() on its own reads "010" as
52
+ # octal and answers about line 8, a wrong answer given confidently. A
53
+ # fraction is refused rather than truncated for the same reason.
54
+ def line_number(raw)
55
+ number = raw.is_a?(Integer) ? raw : Integer(raw.to_s, 10, exception: false)
56
+ raise ValidationError, "line must be a positive integer, got #{raw.inspect}" if number.nil? || number < 1
57
+
58
+ number
59
+ end
60
+
61
+ # Walks in file order, extending the span in hand while the next line both
62
+ # follows the last one and names the same change.
63
+ def collapse(entries)
64
+ entries.each_with_object([]) do |(number, id), spans|
65
+ extends?(spans.last, number, id) ? spans.last.to = number : spans << Span.new(id, number, number)
66
+ end
67
+ end
68
+
69
+ def extends?(span, number, id)
70
+ !span.nil? && span.external_id == id && span.to == number - 1
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,23 @@
1
+ require_relative "../blame/porcelain"
2
+ require_relative "stdin_json"
3
+
4
+ module IntentRecord
5
+ class CLI
6
+ # What `blame` reads from stdin. The JSON shape is the contract; a format
7
+ # names a reader that produces it from what some blame tool already prints.
8
+ module BlameInput
9
+ FORMATS = { "git-porcelain" => Blame::Porcelain }.freeze
10
+
11
+ module_function
12
+
13
+ def read(format, stdin)
14
+ return StdinJson.read(stdin) if format.nil?
15
+
16
+ reader = FORMATS[format]
17
+ raise ValidationError, "Unknown --format #{format}. Known formats: #{FORMATS.keys.join(", ")}" if reader.nil?
18
+
19
+ { "lines" => reader.lines(stdin.read) }
20
+ end
21
+ end
22
+ end
23
+ end
@@ -11,6 +11,8 @@ require_relative "../commands/recent"
11
11
  require_relative "../commands/attach"
12
12
  require_relative "../commands/systems"
13
13
  require_relative "../commands/backfill"
14
+ require_relative "../commands/blame"
15
+ require_relative "blame_input"
14
16
 
15
17
  module IntentRecord
16
18
  class CLI
@@ -122,6 +124,11 @@ module IntentRecord
122
124
  finish! { command.call(stdin_json) }
123
125
  end
124
126
 
127
+ def run_blame
128
+ format = @parser.take_flag("--format")
129
+ finish! { Commands::Blame.new.call(BlameInput.read(format, @streams.stdin)) }
130
+ end
131
+
125
132
  def run_systems
126
133
  finish! { Commands::Systems.new.call }
127
134
  end
@@ -8,10 +8,11 @@ module IntentRecord
8
8
  attach <intent_id> Link more commits / stakeholder refs / related intents (JSON via stdin)
9
9
  show <intent_id> Full intent record
10
10
  lookup <commit> [--vcs name] Intents recorded against a commit (full hash or unique prefix)
11
- search <terms...> [--match all] Substring search over summary and body
11
+ search <terms...> [--match all] Search over summary, body and ticket links, best match first
12
12
  by-source <uri> [--contains] Intents linked to a stakeholder uri (Jira ticket, Confluence page, ...)
13
13
  recent [--limit N] Newest intents first
14
14
  systems Known vcs and stakeholder system names
15
+ blame [--format git-porcelain] Intents for the commits a blame output names (JSON via stdin)
15
16
  backfill [options] Recover intents from commit messages (JSON via stdin, see below)
16
17
  serve [--port N] Start the local web GUI
17
18
 
@@ -28,6 +29,17 @@ module IntentRecord
28
29
  --order <which-first> newest-first (default, as git log prints) or oldest-first
29
30
  --dry-run Report what it would write, write nothing
30
31
 
32
+ Search terms:
33
+ Ranked best first. A plain word also matches its other forms, so `retry`
34
+ finds "retried". A term carrying punctuation is matched literally, so
35
+ `100%` does not match "100 percent". A term may name one field:
36
+ summary:retry, body:retry, uri:ACME-42, title:flaky. The name is read in
37
+ any case. Only those four names count, so a ticket url is searched as itself.
38
+
39
+ Input shape for blame (vcs defaults to git):
40
+ {"vcs": "git", "lines": [{"line": 40, "external_id": "<hash>"}]}
41
+ With --format git-porcelain, `git blame --porcelain` output goes in as-is.
42
+
31
43
  Input shape for backfill:
32
44
  {"commits": [{"commit": "<hash>", "message": "<commit message>",
33
45
  "author": "...", "ref": "<branch name>"}]}
@@ -0,0 +1,74 @@
1
+ require_relative "../blame/spans"
2
+ require_relative "../blame/porcelain"
3
+ require_relative "../asset_version_normalizer"
4
+ require_relative "../formatter"
5
+ require_relative "../models/asset_version"
6
+
7
+ module IntentRecord
8
+ module Commands
9
+ # Answers "why is this line here" for a range of lines.
10
+ #
11
+ # Blame output goes in on stdin rather than the tool shelling out to git, so
12
+ # the same command answers for Perforce or Mercurial and the store keeps
13
+ # knowing nothing about which repository a commit belongs to.
14
+ class Blame
15
+ DEFAULT_VCS = "git".freeze
16
+
17
+ # Git's all-zero object id, which blame gives a line that is in the working
18
+ # copy and not in the history. It is not a commit, so a reader is told the
19
+ # line is not committed rather than that nothing was recorded against it,
20
+ # and the store is not asked about it at all: the id is a valid shape, so
21
+ # a record can sit under it, and one did, but a line with no history cannot
22
+ # have had anything recorded against it.
23
+ # Forty zeros, or sixty-four in a repository using the sha256 object format.
24
+ UNCOMMITTED = /\A(?:0{40}|0{64})\z/
25
+
26
+ def call(input)
27
+ vcs = AssetVersionNormalizer.vcs_name(input["vcs"] || DEFAULT_VCS)
28
+ spans = IntentRecord::Blame::Spans.new(input["lines"]).call
29
+ { "spans" => answers(spans, vcs) }
30
+ end
31
+
32
+ private
33
+
34
+ def answers(spans, vcs)
35
+ known = versions(spans.reject { |span| uncommitted?(span, vcs) }, vcs)
36
+ spans.map { |span| answer(span, vcs, known[AssetVersionNormalizer.lookup_id(vcs, span.external_id)]) }
37
+ end
38
+
39
+ # Every span is answered, including one whose change the store has never
40
+ # heard of. Leaving it out would read as "this line has no history", which
41
+ # is the opposite of what an empty list of intents says.
42
+ def answer(span, vcs, version)
43
+ answered = {
44
+ "from" => span.from,
45
+ "to" => span.to,
46
+ "asset_version" => { "vcs" => vcs, "external_id" => span.external_id },
47
+ "intents" => intents(version)
48
+ }
49
+ uncommitted?(span, vcs) ? answered.merge("uncommitted" => true) : answered
50
+ end
51
+
52
+ def uncommitted?(span, vcs)
53
+ vcs == DEFAULT_VCS && UNCOMMITTED.match?(span.external_id)
54
+ end
55
+
56
+ def intents(version)
57
+ return [] if version.nil?
58
+
59
+ version.intent_records.map { |record| Formatter.full(record) }
60
+ end
61
+
62
+ # Read once for the whole payload rather than per span. A blamed range is a
63
+ # handful of changes however many lines it covers, and asking per span
64
+ # would make the query count grow with the size of the range.
65
+ def versions(spans, vcs)
66
+ ids = spans.map { |span| AssetVersionNormalizer.lookup_id(vcs, span.external_id) }.uniq
67
+ scope = Models::AssetVersion.joins(:vcs_system)
68
+ .where(vcs_systems: { name: vcs }, external_id: ids)
69
+ .preload(intent_records: Formatter::PRELOADS)
70
+ scope.index_by(&:external_id)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -14,7 +14,7 @@ module IntentRecord
14
14
  version = AssetVersionResolver.new(external_id: @external_id, vcs: @vcs).call
15
15
  {
16
16
  "asset_version" => Formatter.asset_version(version),
17
- "intents" => Formatter.preloaded(version.intent_records.order(:created_at)).map { |r| Formatter.full(r) }
17
+ "intents" => Formatter.preloaded(version.intent_records).map { |r| Formatter.full(r) }
18
18
  }
19
19
  end
20
20
  end
@@ -1,49 +1,76 @@
1
1
  require_relative "../formatter"
2
- require_relative "../like_pattern"
2
+ require_relative "../match_expression"
3
+ require_relative "../search_membership"
4
+ require_relative "../search_ranking"
5
+ require_relative "../search_snippet"
6
+ require_relative "../search_term"
3
7
  require_relative "../models/intent_record"
4
8
 
5
9
  module IntentRecord
6
10
  module Commands
7
- # Case-insensitive substring search over summary, body, and linked stakeholder uris and titles,
8
- # so a ticket key such as ACME-42 finds the intents built for it.
11
+ # Search over summary, body, and linked stakeholder uris and titles, so a
12
+ # ticket key such as ACME-42 finds the intents built for it.
13
+ #
14
+ # Which records answer is SearchMembership's decision and what order they
15
+ # answer in is SearchRanking's. The two are kept apart because they read a
16
+ # term differently, and only one of them may be trusted with a term that
17
+ # carries punctuation.
9
18
  class Search
10
19
  LIMIT = 200
11
- FIELDS = %w[intent_records.summary intent_records.body stakeholder_sources.uri stakeholder_sources.title].freeze
20
+ MATCH_MODES = %w[any all].freeze
12
21
 
22
+ # Refuses bad input here, before the store is asked anything.
13
23
  def initialize(terms:, match: "any")
14
- @terms = terms.map(&:strip).reject(&:empty?)
24
+ raw = terms.map(&:strip).reject(&:empty?)
25
+ raise ValidationError, "At least one search term is required" if raw.empty?
26
+ raise ValidationError, "--match must be any or all" unless MATCH_MODES.include?(match)
27
+
28
+ @terms = raw.map { |term| SearchTerm.parse(term) }
15
29
  @match = match
16
30
  end
17
31
 
18
32
  def call
19
- raise ValidationError, "At least one search term is required" if @terms.empty?
20
- raise ValidationError, "--match must be any or all" unless %w[any all].include?(@match)
21
-
22
- { "intents" => matching_records.map { |r| Formatter.full(r) } }
33
+ { "intents" => matching_records.map { |r| formatted(r) } }
23
34
  end
24
35
 
25
36
  private
26
37
 
38
+ # The snippet belongs to the search rather than to the record, so it is
39
+ # merged here instead of in Formatter, which every other command shares.
40
+ def formatted(record)
41
+ snippet = SearchSnippet.text(indexed: record[SearchSnippet::COLUMN], body: record.body)
42
+ Formatter.full(record).merge(SearchSnippet::COLUMN => snippet)
43
+ end
44
+
27
45
  def matching_records
28
- Formatter.preloaded(
29
- Models::IntentRecord.left_joins(:stakeholder_sources)
30
- .group("intent_records.id")
31
- .having(having_sql, *having_binds)
32
- .order(created_at: :desc).limit(LIMIT)
33
- )
46
+ Formatter.preloaded(with_ranking(matching_scope))
34
47
  end
35
48
 
36
- def having_sql
37
- joiner = @match == "all" ? " AND " : " OR "
38
- @terms.map { "SUM(CASE WHEN #{term_clause} THEN 1 ELSE 0 END) > 0" }.join(joiner)
49
+ def matching_scope
50
+ Models::IntentRecord.left_joins(:stakeholder_sources)
51
+ .group("intent_records.id")
52
+ .having(*SearchMembership.new(terms: @terms, match: @match).condition)
53
+ .limit(LIMIT)
39
54
  end
40
55
 
41
- def term_clause
42
- FIELDS.map { |f| LikePattern.contains(f) }.join(" OR ")
56
+ # Only the terms the index may answer rank anything. A term it may not be
57
+ # trusted with would rank by text the person did not ask for, which is the
58
+ # same reason it does not decide membership either.
59
+ def indexable
60
+ @terms.select(&:indexable?)
43
61
  end
44
62
 
45
- def having_binds
46
- @terms.flat_map { |t| [LikePattern.contains_bind(t)] * FIELDS.size }
63
+ # Joins the relevance and fragment the index found, and orders by them.
64
+ # Terms that hold no searchable token leave nothing to rank, and an empty
65
+ # MATCH is a syntax error rather than an expression matching nothing, so
66
+ # that case orders by the tie-break alone and the body stands in for the
67
+ # fragment.
68
+ def with_ranking(scope)
69
+ expression = MatchExpression.for(indexable)
70
+ return scope.order(Arel.sql(SearchRanking::TIE_BREAK)) if expression.nil?
71
+
72
+ join = Models::IntentRecord.sanitize_sql_array([SearchRanking::JOIN, expression])
73
+ scope.select(SearchRanking::SELECTION).joins(join).order(Arel.sql(SearchRanking::ORDER))
47
74
  end
48
75
  end
49
76
  end
@@ -0,0 +1,50 @@
1
+ module IntentRecord
2
+ # Turns the terms a user typed into an FTS5 MATCH expression.
3
+ #
4
+ # FTS5 reads its right-hand side as an expression language, not as text: `100%`
5
+ # is a syntax error and a term such as `OR` or `NEAR` is an operator. Quoting
6
+ # each term makes it a phrase, which is the only form that carries whatever the
7
+ # user typed through the parser unread.
8
+ #
9
+ # Phrases are joined with OR. The expression is used twice, and OR is right
10
+ # for both: SearchMembership asks the index one term at a time, so an
11
+ # expression there holds a single phrase and the joiner never applies; and
12
+ # SearchRanking asks it about every term at once, where a record is scored on
13
+ # whichever terms it holds rather than dropped for lacking one, because which
14
+ # records answer has already been settled and AND would score some of them at
15
+ # nothing.
16
+ module MatchExpression
17
+ JOINER = " OR ".freeze
18
+ QUOTE = '"'.freeze
19
+
20
+ module_function
21
+
22
+ # Takes SearchTerms, which carry the text and the column the person named.
23
+ def for(terms)
24
+ phrases = terms.map { |term| phrase(term) }.compact
25
+ return nil if phrases.empty?
26
+
27
+ phrases.join(JOINER)
28
+ end
29
+
30
+ def phrase(term)
31
+ # A term of pure punctuation tokenises to nothing, and an empty phrase is
32
+ # a syntax error rather than a phrase that matches nothing.
33
+ return nil unless term.text.match?(/[[:alnum:]]/)
34
+
35
+ column(term.index_column) + quoted(term.text)
36
+ end
37
+
38
+ # FTS5 reads `summary:"retry"` as that phrase in that column only. A term
39
+ # naming no column is left unprefixed and is answered over all of them.
40
+ def column(name)
41
+ name.nil? ? "" : "#{name}:"
42
+ end
43
+
44
+ def quoted(text)
45
+ "#{QUOTE}#{text.gsub(QUOTE, QUOTE * 2)}#{QUOTE}"
46
+ end
47
+
48
+ private_class_method :phrase, :column, :quoted
49
+ end
50
+ end
@@ -4,7 +4,9 @@ module IntentRecord
4
4
  class AssetVersion < ApplicationRecord
5
5
  belongs_to :vcs_system
6
6
  has_many :intent_record_asset_versions, dependent: :destroy
7
- has_many :intent_records, through: :intent_record_asset_versions
7
+ # Oldest first, then id, so a commit carrying several intents reads as a
8
+ # story and the order is the query's rather than the plan's or Ruby's.
9
+ has_many :intent_records, -> { order(:created_at, :id) }, through: :intent_record_asset_versions
8
10
 
9
11
  validates :external_id, presence: true, uniqueness: { scope: :vcs_system_id }
10
12
  end
@@ -0,0 +1,7 @@
1
+ module IntentRecord
2
+ # The FTS5 table over intent records, created by the migration of the same
3
+ # name. Named here once, for the two modules that read it.
4
+ module SearchIndex
5
+ TABLE = "intent_search".freeze
6
+ end
7
+ end
@@ -0,0 +1,62 @@
1
+ require_relative "like_pattern"
2
+ require_relative "match_expression"
3
+ require_relative "search_ranking"
4
+ require_relative "search_term"
5
+
6
+ module IntentRecord
7
+ # Which records a search answers with.
8
+ #
9
+ # Two ways of matching sit behind one condition. Substring matching reads a
10
+ # term as the literal it is, which is what a person typing `100%` or `ACME-4`
11
+ # is asking for. The index reads it as a word, which is what finds `retried`
12
+ # for someone who typed `retry`.
13
+ #
14
+ # They are not interchangeable, and the index is the narrower of the two in
15
+ # one direction and the wider in the other. It cannot see inside a word, so it
16
+ # would lose `ACME-4` in `ACME-42`; and it drops punctuation, so, measured, it
17
+ # matches `100 percent` for `100%` and `done now` for `done_now`. A term the
18
+ # index may not be trusted with therefore gets the substring arm alone, which
19
+ # SearchTerm#indexable? decides.
20
+ class SearchMembership
21
+ JOINERS = { "all" => " AND ", "any" => " OR " }.freeze
22
+
23
+ def initialize(terms:, match:)
24
+ @terms = terms
25
+ @match = match
26
+ end
27
+
28
+ # Ready for `having`: the condition followed by its binds, in order.
29
+ def condition
30
+ clauses = @terms.map { |term| clause(term) }
31
+ [clauses.map(&:first).join(JOINERS.fetch(@match)), *clauses.flat_map { |c| c.drop(1) }]
32
+ end
33
+
34
+ private
35
+
36
+ def clause(term)
37
+ arms = [substring_arm(term), *index_arm(term)]
38
+ ["(#{arms.join(" OR ")})", *substring_binds(term), *index_binds(term)]
39
+ end
40
+
41
+ # Counted rather than tested for, because the join to stakeholder_sources
42
+ # gives a record one row per source and the condition is read after grouping.
43
+ def substring_arm(term)
44
+ "SUM(CASE WHEN #{term.fields.map { |f| LikePattern.contains(f) }.join(" OR ")} THEN 1 ELSE 0 END) > 0"
45
+ end
46
+
47
+ def substring_binds(term)
48
+ [LikePattern.contains_bind(term.text)] * term.fields.size
49
+ end
50
+
51
+ def index_arm(term)
52
+ return [] unless term.indexable?
53
+
54
+ ["EXISTS (SELECT 1 FROM #{SearchRanking::TABLE} " \
55
+ "WHERE #{SearchRanking::TABLE} MATCH ? AND #{SearchRanking::TABLE}.rowid = intent_records.id)"]
56
+ end
57
+
58
+ def index_binds(term)
59
+ term.indexable? ? [MatchExpression.for([term])] : []
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,57 @@
1
+ require_relative "search_index"
2
+ require_relative "search_snippet"
3
+
4
+ module IntentRecord
5
+ # The order a search answers in.
6
+ #
7
+ # Which records match is settled by the substring search, because that is what
8
+ # a person typing `100%` or `ACME-4` is asking for and FTS5 cannot answer it:
9
+ # it tokenises, so it neither sees a substring inside a word nor tells `100%`
10
+ # from `100 percent`. What it can do is say which of the matches the terms are
11
+ # actually about, and that is all it is asked here.
12
+ #
13
+ # bm25 returns a negative score, better the lower, so ascending is best first.
14
+ # A record the index cannot see scores nothing and sorts after every record it
15
+ # can, which is the right place for a hit the terms only touch as a substring.
16
+ module SearchRanking
17
+ TABLE = SearchIndex::TABLE
18
+ ALIAS = "ranking".freeze
19
+
20
+ # Weights per indexed column, in the order the index declares them. A term in
21
+ # a one-line summary is a stronger signal than the same term somewhere in a
22
+ # body that may run for pages, and bm25's own length normalisation does not
23
+ # say so on its own.
24
+ SUMMARY_WEIGHT = 10.0
25
+ BODY_WEIGHT = 1.0
26
+
27
+ # Every ranked list needs a total order, or the rows that tie come back in
28
+ # whatever order the plan produces. Newest first among equals, then id, which
29
+ # is the order `recent` already answers in.
30
+ TIE_BREAK = "intent_records.created_at DESC, intent_records.id DESC".freeze
31
+
32
+ NO_MATCH_SCORE = 0.0
33
+
34
+ # The relevance and the fragment for every record the terms match, computed
35
+ # once for the search and joined to the candidates. Once, not once per
36
+ # record: a correlated subquery measured eight seconds for a common word on
37
+ # twenty-five thousand records, this a quarter of a second. `LIMIT -1` is
38
+ # what keeps SQLite from flattening the subquery into the outer query, where
39
+ # bm25 and snippet lose their context; the plan reports it as MATERIALIZE,
40
+ # and SearchRankingPlanTest holds it there. The `?` is filled by the caller
41
+ # through sanitize_sql_array.
42
+ JOIN = <<~SQL.squish.freeze
43
+ LEFT JOIN (
44
+ SELECT rowid AS indexed_id,
45
+ bm25(#{TABLE}, #{SUMMARY_WEIGHT}, #{BODY_WEIGHT}) AS relevance,
46
+ #{SearchSnippet::EXPRESSION} AS #{SearchSnippet::COLUMN}
47
+ FROM #{TABLE} WHERE #{TABLE} MATCH ? LIMIT -1
48
+ ) AS #{ALIAS} ON #{ALIAS}.indexed_id = intent_records.id
49
+ SQL
50
+
51
+ ORDER = "COALESCE(#{ALIAS}.relevance, #{NO_MATCH_SCORE}) ASC, #{TIE_BREAK}".freeze
52
+
53
+ # What a search selects when it ranks: the record, and the fragment the
54
+ # subquery found for it.
55
+ SELECTION = "intent_records.*, #{ALIAS}.#{SearchSnippet::COLUMN} AS #{SearchSnippet::COLUMN}".freeze
56
+ end
57
+ end
@@ -0,0 +1,52 @@
1
+ require_relative "search_index"
2
+
3
+ module IntentRecord
4
+ # What a search result shows instead of the whole body.
5
+ #
6
+ # A result carries the whole body, which is right for an agent reading one
7
+ # record and wrong for a person scanning two hundred. The index can say which
8
+ # part of a body the terms landed in, and that is the part worth reading first.
9
+ #
10
+ # It can only say it for a record it matched, though, and a term matched as a
11
+ # substring inside a longer word is one it never saw. Those fall back to the
12
+ # opening of the body, so every result carries something rather than leaving a
13
+ # blank in a list where every other row has text.
14
+ module SearchSnippet
15
+ # Roughly a sentence or two either side of the match, which is enough to see
16
+ # why a record answered without turning the list back into a wall of prose.
17
+ INDEX_TOKENS = 20
18
+ FALLBACK_LENGTH = 200
19
+ ELLIPSIS = "…".freeze
20
+
21
+ # Marks are left empty deliberately. A fragment with `[` and `]` around the
22
+ # match reads as punctuation the writer typed, and a fragment with HTML in it
23
+ # is either escaped into visible markup or trusted, and neither is wanted.
24
+ # Column 1 is the body. Evaluated inside SearchRanking's subquery, which is
25
+ # the one place a search still holds the index's context.
26
+ EXPRESSION = "snippet(#{SearchIndex::TABLE}, 1, '', '', '#{ELLIPSIS}', #{INDEX_TOKENS})".freeze
27
+
28
+ COLUMN = "snippet".freeze
29
+
30
+ module_function
31
+
32
+ def text(indexed:, body:)
33
+ return indexed unless indexed.nil? || indexed.empty?
34
+
35
+ shortened(body.to_s)
36
+ end
37
+
38
+ # Cut at the last space where there is one, so the reader is not shown a
39
+ # fragment of a word. Japanese and Chinese prose has no spaces, and neither
40
+ # does a long url or a hash, and a cut that found no space and kept nothing
41
+ # showed an ellipsis on its own.
42
+ def shortened(body)
43
+ return body if body.length <= FALLBACK_LENGTH
44
+
45
+ cut = body[0, FALLBACK_LENGTH]
46
+ at_word = cut.rpartition(/\s/).first.rstrip
47
+ "#{at_word.empty? ? cut : at_word}#{ELLIPSIS}"
48
+ end
49
+
50
+ private_class_method :shortened
51
+ end
52
+ end
@@ -0,0 +1,59 @@
1
+ module IntentRecord
2
+ # One term a person typed, read into the fields it asks about and the text to
3
+ # look for in them.
4
+ #
5
+ # A term may name a field, as in `summary:retry`, for the case where a common
6
+ # word appears everywhere and ranking it first is not enough. The hazard that
7
+ # comes with it is that a ticket uri carries a colon too, so only the known
8
+ # names count as a name and anything else before a colon stays part of the term.
9
+ class SearchTerm
10
+ RECORD_FIELDS = { "summary" => "intent_records.summary", "body" => "intent_records.body" }.freeze
11
+ STAKEHOLDER_FIELDS = { "uri" => "stakeholder_sources.uri", "title" => "stakeholder_sources.title" }.freeze
12
+ FIELDS = RECORD_FIELDS.merge(STAKEHOLDER_FIELDS).freeze
13
+ ALL_FIELDS = FIELDS.values.freeze
14
+
15
+ # Case-insensitive, as every other name the tool takes is.
16
+ NAMED = /\A(#{FIELDS.keys.join("|")}):(.*)\z/mi
17
+
18
+ # Text the index may be trusted with: letters, digits and the spaces between
19
+ # them, with at least one letter or digit to tokenise. Anything else the
20
+ # index would drop, and dropping punctuation is how `100%` comes to match a
21
+ # record that only ever said `100 percent`.
22
+ WORD = /\A(?=.*[[:alnum:]])[[:alnum:][:space:]]+\z/
23
+
24
+ attr_reader :text, :fields, :index_column
25
+
26
+ def self.parse(raw)
27
+ match = NAMED.match(raw)
28
+ return new(text: raw, fields: ALL_FIELDS, index_column: nil) if match.nil?
29
+
30
+ named(match[1].downcase, match[2])
31
+ end
32
+
33
+ # The shell hands `uri: acme-42` over as one word with the space inside,
34
+ # and the space is not part of what is being looked for.
35
+ def self.named(name, text)
36
+ text = text.strip
37
+ raise ValidationError, "#{name}: needs something to look for" if text.empty?
38
+
39
+ new(text: text, fields: [FIELDS.fetch(name)], index_column: RECORD_FIELDS.key?(name) ? name : nil)
40
+ end
41
+
42
+ def initialize(text:, fields:, index_column:)
43
+ @text = text
44
+ @fields = fields
45
+ @index_column = index_column
46
+ # Named a stakeholder field, which the index does not hold. Distinguished
47
+ # from a plain term, where a nil column means every column of the index.
48
+ @off_index = index_column.nil? && fields != ALL_FIELDS
49
+ end
50
+
51
+ # Whether the index may answer this term at all: not for a field it does
52
+ # not hold, and not for text it would read differently from the person.
53
+ def indexable?
54
+ !@off_index && WORD.match?(text)
55
+ end
56
+
57
+ private_class_method :named
58
+ end
59
+ end
@@ -1,3 +1,3 @@
1
1
  module IntentRecord
2
- VERSION = "1.1.0".freeze
2
+ VERSION = "1.2.0".freeze
3
3
  end
@@ -11,7 +11,9 @@
11
11
  · <code class="id"><%= h r["intent_id"] %></code>
12
12
  </div>
13
13
  </header>
14
- <div class="body"><%= h r["body"] %></div>
14
+ <%# A search result carries the fragment its terms landed in; every other
15
+ page shows one record, where the body is what the reader came for. %>
16
+ <div class="body"><%= h(r["snippet"] || r["body"]) %></div>
15
17
  <% if r["asset_versions"].any? %>
16
18
  <div class="links">
17
19
  <span class="label">Commits</span>
@@ -1,5 +1,5 @@
1
1
  <header>
2
2
  <h1>Search: <%= h query %></h1>
3
- <p class="subtitle">Intents whose summary, body, or linked ticket URL or title contain every word</p>
3
+ <p class="subtitle">Intents matching every word, most relevant first</p>
4
4
  </header>
5
5
  <%= intent_cards(intents) %>
data/lib/intent_record.rb CHANGED
@@ -16,6 +16,12 @@ end
16
16
  require "intent_record/config"
17
17
  require "intent_record/database"
18
18
  require "intent_record/global_id"
19
+ require "intent_record/match_expression"
20
+ require "intent_record/search_index"
21
+ require "intent_record/search_ranking"
22
+ require "intent_record/search_term"
23
+ require "intent_record/search_membership"
24
+ require "intent_record/search_snippet"
19
25
  require "intent_record/application_record"
20
26
  require "intent_record/models/vcs_system"
21
27
  require "intent_record/models/asset_version"
@@ -25,5 +31,7 @@ require "intent_record/models/stakeholder_system"
25
31
  require "intent_record/models/stakeholder_source"
26
32
  require "intent_record/models/stakeholder_reference"
27
33
  require "intent_record/models/intent_record_link"
34
+ require "intent_record/blame/spans"
35
+ require "intent_record/blame/porcelain"
28
36
  require "intent_record/backfill"
29
37
  require "intent_record/cli"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: intent-record
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Erik T. Madsen
@@ -107,6 +107,7 @@ files:
107
107
  - LICENSE.txt
108
108
  - README.md
109
109
  - db/migrate/20260918000001_create_initial_schema.rb
110
+ - db/migrate/20260920000001_create_search_index.rb
110
111
  - exe/intent-record
111
112
  - lib/intent_record.rb
112
113
  - lib/intent_record/application_record.rb
@@ -118,14 +119,18 @@ files:
118
119
  - lib/intent_record/backfill/inspection.rb
119
120
  - lib/intent_record/backfill/predecessor_finder.rb
120
121
  - lib/intent_record/backfill/reference_scanner.rb
122
+ - lib/intent_record/blame/porcelain.rb
123
+ - lib/intent_record/blame/spans.rb
121
124
  - lib/intent_record/cli.rb
122
125
  - lib/intent_record/cli/argv_parser.rb
126
+ - lib/intent_record/cli/blame_input.rb
123
127
  - lib/intent_record/cli/dispatch.rb
124
128
  - lib/intent_record/cli/stdin_json.rb
125
129
  - lib/intent_record/cli/streams.rb
126
130
  - lib/intent_record/cli/usage.rb
127
131
  - lib/intent_record/commands/attach.rb
128
132
  - lib/intent_record/commands/backfill.rb
133
+ - lib/intent_record/commands/blame.rb
129
134
  - lib/intent_record/commands/by_source.rb
130
135
  - lib/intent_record/commands/lookup.rb
131
136
  - lib/intent_record/commands/recent.rb
@@ -143,6 +148,7 @@ files:
143
148
  - lib/intent_record/linkers/asset_version_specs.rb
144
149
  - lib/intent_record/linkers/intent_linker.rb
145
150
  - lib/intent_record/linkers/stakeholder_linker.rb
151
+ - lib/intent_record/match_expression.rb
146
152
  - lib/intent_record/models/asset_version.rb
147
153
  - lib/intent_record/models/intent_record.rb
148
154
  - lib/intent_record/models/intent_record_asset_version.rb
@@ -151,6 +157,11 @@ files:
151
157
  - lib/intent_record/models/stakeholder_source.rb
152
158
  - lib/intent_record/models/stakeholder_system.rb
153
159
  - lib/intent_record/models/vcs_system.rb
160
+ - lib/intent_record/search_index.rb
161
+ - lib/intent_record/search_membership.rb
162
+ - lib/intent_record/search_ranking.rb
163
+ - lib/intent_record/search_snippet.rb
164
+ - lib/intent_record/search_term.rb
154
165
  - lib/intent_record/seeds.rb
155
166
  - lib/intent_record/sqlite_connection_setup.rb
156
167
  - lib/intent_record/stakeholder_normalizer.rb