@hraness/wordcell 0.25.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,14 +54,14 @@ Wordcell keeps the record in Markdown files you own, rebuilds every index from t
54
54
  The CLI and TypeScript SDK run with Bun. Install the versioned GitHub archive:
55
55
 
56
56
  ```sh
57
- bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.25.0/hraness-wordcell-0.25.0.tgz
57
+ bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.25.1/hraness-wordcell-0.25.1.tgz
58
58
  wordcell --help
59
59
  ```
60
60
 
61
61
  Prefer npm? The same release is [mirrored there](https://www.npmjs.com/package/@hraness/wordcell):
62
62
 
63
63
  ```sh
64
- npm install --global --ignore-scripts @hraness/wordcell@0.25.0
64
+ npm install --global --ignore-scripts @hraness/wordcell@0.25.1
65
65
  wordcell --help
66
66
  ```
67
67
 
@@ -103,7 +103,7 @@ After trying the CLI, install the public Agent Skill into a compatible agent,
103
103
  such as Claude Code, Codex, Cursor, or GitHub Copilot:
104
104
 
105
105
  ```sh
106
- bunx skills add hraness/wordcell#v0.25.0 --skill wordcell
106
+ bunx skills add hraness/wordcell#v0.25.1 --skill wordcell
107
107
  ```
108
108
 
109
109
  Then ask:
@@ -334,7 +334,7 @@ exist. [Graph queries and proof limits](https://github.com/hraness/wordcell/blob
334
334
  Add the same immutable release to a Bun project:
335
335
 
336
336
  ```sh
337
- bun add --exact --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.25.0/hraness-wordcell-0.25.0.tgz
337
+ bun add --exact --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.25.1/hraness-wordcell-0.25.1.tgz
338
338
  ```
339
339
 
340
340
  The SDK provides read-only vault sessions, metadata queries, search, graph
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/wordcell",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "description": "Wordcell keeps decisions, plans, and sources as Markdown beside your code, so coding agents can find them from the file they are about to change.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -58,7 +58,7 @@ missing:
58
58
  ```sh
59
59
  command -v wordcell >/dev/null 2>&1 || {
60
60
  command -v bun >/dev/null 2>&1 || exit 1
61
- bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.25.0/hraness-wordcell-0.25.0.tgz
61
+ bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.25.1/hraness-wordcell-0.25.1.tgz
62
62
  }
63
63
  wordcell --help
64
64
  ```
@@ -170,8 +170,8 @@ fn parse(body: &str, limit: usize, query: &str) -> Outcome {
170
170
  Some(cost)
171
171
  }
172
172
  };
173
- let mut seen = std::collections::HashSet::new();
174
- let mut seen_ids = std::collections::HashSet::new();
173
+ let mut known_retracted_ids = std::collections::HashSet::new();
174
+ let mut observed_ids = Vec::with_capacity(rows.len());
175
175
  let mut results = Vec::new();
176
176
  for row in rows {
177
177
  let id = row
@@ -191,6 +191,18 @@ fn parse(body: &str, limit: usize, query: &str) -> Outcome {
191
191
  if title.trim().is_empty() || title.len() > MAX_TEXT {
192
192
  return Err(FailureCode::Parse);
193
193
  }
194
+ let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
195
+ // Absence is unknown; only an explicit flag or title marker excludes a work.
196
+ let retracted = match row.get("is_retracted") {
197
+ None => false,
198
+ Some(value) => value.as_bool().ok_or(FailureCode::Parse)?,
199
+ };
200
+ const RETRACTED_PREFIX: &str = "RETRACTED ARTICLE:";
201
+ let retracted = retracted
202
+ || title
203
+ .get(..RETRACTED_PREFIX.len())
204
+ .is_some_and(|prefix| prefix.eq_ignore_ascii_case(RETRACTED_PREFIX));
205
+ observed_ids.push(id);
194
206
  let mut candidates = Vec::new();
195
207
  let mut locations = Vec::new();
196
208
  for field in ["best_oa_location", "primary_location"] {
@@ -270,21 +282,41 @@ fn parse(body: &str, limit: usize, query: &str) -> Outcome {
270
282
  }
271
283
  _ => return Err(FailureCode::Parse),
272
284
  }
285
+ // Validate every row's bounded fields even when its work will be omitted.
286
+ if retracted {
287
+ known_retracted_ids.insert(id);
288
+ continue;
289
+ }
273
290
  // Prefer observed direct destinations over DOI redirectors; preserve stable location order.
274
291
  candidates.sort_by_key(|(priority, _)| *priority);
275
292
  let Some((_, url)) = candidates.into_iter().next() else {
276
293
  continue;
277
294
  };
278
- if seen_ids.insert(id.to_owned()) && seen.insert(url.clone()) {
279
- results.push(SearchResult {
280
- title: title.split_whitespace().collect::<Vec<_>>().join(" "),
295
+ results.push((
296
+ id,
297
+ SearchResult {
298
+ title,
281
299
  url,
282
300
  snippet: Some("Scholarly index metadata; full text not fetched.".into()),
283
301
  source_engine: "openalex".into(),
284
- });
285
- }
302
+ },
303
+ ));
286
304
  }
287
- if !rows.is_empty() && results.is_empty() {
305
+ // A duplicate row cannot reintroduce an identity explicitly marked retracted.
306
+ // Deduplicate only admitted works so excluded identities cannot hide usable rows.
307
+ let mut seen = std::collections::HashSet::new();
308
+ let mut seen_ids = std::collections::HashSet::new();
309
+ let results: Vec<_> = results
310
+ .into_iter()
311
+ .filter(|(id, _)| !known_retracted_ids.contains(id))
312
+ .filter(|(id, result)| seen_ids.insert(*id) && seen.insert(result.url.clone()))
313
+ .map(|(_, result)| result)
314
+ .collect();
315
+ if results.is_empty()
316
+ && observed_ids
317
+ .iter()
318
+ .any(|id| !known_retracted_ids.contains(id))
319
+ {
288
320
  return Err(FailureCode::UnverifiedEmpty);
289
321
  }
290
322
  Ok(Discovery {
@@ -307,6 +339,122 @@ mod tests {
307
339
  parse(&value.to_string(), 3, "microalgae")
308
340
  }
309
341
  #[test]
342
+ fn omits_known_retractions_and_preserves_cost_for_policy_empty_results() {
343
+ let mut retracted = row();
344
+ retracted["is_retracted"] = json!(true);
345
+ let mut value = response(vec![retracted.clone()]);
346
+ value["meta"]["cost_usd"] = json!(0.0001);
347
+ let result = check(value).unwrap();
348
+ assert!(result.results.is_empty());
349
+ assert_eq!(result.reported_cost_usd, Some(0.0001));
350
+
351
+ let mut good = row();
352
+ good["id"] = json!("https://openalex.org/W456");
353
+ good["is_retracted"] = json!(false);
354
+ let result = check(response(vec![retracted, good])).unwrap();
355
+ assert_eq!(result.results.len(), 1);
356
+ assert_eq!(
357
+ result.results[0].title,
358
+ "Microalgae harvesting energy efficiency"
359
+ );
360
+ // Missing status remains admissible metadata, without a verified-status claim.
361
+ let result = check(response(vec![row()])).unwrap();
362
+ assert_eq!(result.results.len(), 1);
363
+ assert_eq!(
364
+ result.results[0].snippet.as_deref(),
365
+ Some("Scholarly index metadata; full text not fetched.")
366
+ );
367
+ }
368
+ #[test]
369
+ fn retraction_marker_is_anchored_and_does_not_exclude_scholarship_about_retractions() {
370
+ for title in [
371
+ "RETRACTED ARTICLE: Microalgae",
372
+ " retracted article:Microalgae",
373
+ "RETRACTED\n ARTICLE: Microalgae",
374
+ ] {
375
+ let mut r = row();
376
+ r["title"] = json!(title);
377
+ r["is_retracted"] = json!(false);
378
+ assert!(
379
+ check(response(vec![r])).unwrap().results.is_empty(),
380
+ "{title}"
381
+ );
382
+ }
383
+ for title in [
384
+ "A study of retractions",
385
+ "Analysis of RETRACTED ARTICLE: labels",
386
+ "RETRACTED ARTICLES: a review",
387
+ "Retraction notices in science",
388
+ "論文について",
389
+ ] {
390
+ let mut r = row();
391
+ r["title"] = json!(title);
392
+ assert_eq!(
393
+ check(response(vec![r])).unwrap().results.len(),
394
+ 1,
395
+ "{title}"
396
+ );
397
+ }
398
+ }
399
+ #[test]
400
+ fn rejects_malformed_status_even_with_an_explicit_title_marker() {
401
+ for status in [Value::Null, json!("true"), json!(1), json!([]), json!({})] {
402
+ for title in ["Microalgae harvesting", "RETRACTED ARTICLE: Microalgae"] {
403
+ let mut r = row();
404
+ r["title"] = json!(title);
405
+ r["is_retracted"] = status.clone();
406
+ assert_eq!(check(response(vec![r])).unwrap_err(), FailureCode::Parse);
407
+ }
408
+ }
409
+ }
410
+ #[test]
411
+ fn policy_filter_preserves_unusable_link_failures_and_row_validation() {
412
+ let mut retracted = row();
413
+ retracted["is_retracted"] = json!(true);
414
+ let mut unusable = row();
415
+ unusable["id"] = json!("https://openalex.org/W456");
416
+ unusable["best_oa_location"] = Value::Null;
417
+ unusable["doi"] = Value::Null;
418
+ assert_eq!(
419
+ check(response(vec![retracted.clone(), unusable])).unwrap_err(),
420
+ FailureCode::UnverifiedEmpty
421
+ );
422
+ retracted["doi"] = json!(42);
423
+ assert_eq!(
424
+ check(response(vec![retracted.clone()])).unwrap_err(),
425
+ FailureCode::Parse
426
+ );
427
+ retracted["doi"] = Value::Null;
428
+ retracted["locations"] = json!([{"is_oa":true,"landing_page_url":"x".repeat(8193)}]);
429
+ assert_eq!(
430
+ check(response(vec![retracted])).unwrap_err(),
431
+ FailureCode::BodyLimit
432
+ );
433
+ }
434
+ #[test]
435
+ fn duplicate_work_cannot_reintroduce_a_known_retraction_in_either_order() {
436
+ let mut retracted = row();
437
+ retracted["is_retracted"] = json!(true);
438
+ for rows in [vec![row(), retracted.clone()], vec![retracted, row()]] {
439
+ assert!(check(response(rows)).unwrap().results.is_empty());
440
+ }
441
+ }
442
+ #[test]
443
+ fn excluded_identity_cannot_consume_a_valid_works_url_deduplication_slot() {
444
+ let mut retracted = row();
445
+ retracted["is_retracted"] = json!(true);
446
+ let mut good = row();
447
+ good["id"] = json!("https://openalex.org/W456");
448
+ good["title"] = json!("Allowed work at the shared destination");
449
+ let result = check(response(vec![row(), good, retracted])).unwrap();
450
+ assert_eq!(result.results.len(), 1);
451
+ assert_eq!(
452
+ result.results[0].title,
453
+ "Allowed work at the shared destination"
454
+ );
455
+ assert_eq!(result.results[0].url, "https://repository.example/paper");
456
+ }
457
+ #[test]
310
458
  fn discovery_prefers_observed_oa_landing_and_deduplicates() {
311
459
  let results = check(response(vec![row(), row()])).unwrap().results;
312
460
  assert_eq!(results.len(), 1);