mandrel 2.3.0 → 2.5.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.
Files changed (76) hide show
  1. package/.agents/audit-checklists/accessibility.md +29 -0
  2. package/.agents/audit-checklists/architecture.md +4 -5
  3. package/.agents/audit-checklists/clean-code.md +10 -0
  4. package/.agents/audit-checklists/data-model.md +22 -0
  5. package/.agents/audit-checklists/dependencies.md +11 -2
  6. package/.agents/audit-checklists/devops.md +4 -0
  7. package/.agents/audit-checklists/navigability.md +3 -0
  8. package/.agents/audit-checklists/performance.md +8 -11
  9. package/.agents/audit-checklists/privacy.md +3 -4
  10. package/.agents/audit-checklists/quality.md +2 -0
  11. package/.agents/audit-checklists/security.md +4 -5
  12. package/.agents/audit-checklists/seo.md +7 -1
  13. package/.agents/audit-checklists/sre.md +14 -12
  14. package/.agents/audit-checklists/ux-ui.md +4 -0
  15. package/.agents/docs/configuration.md +3 -0
  16. package/.agents/docs/workflows.md +4 -3
  17. package/.agents/schemas/agentrc.schema.json +17 -0
  18. package/.agents/schemas/audit-rules.json +134 -19
  19. package/.agents/schemas/audit-rules.schema.json +6 -2
  20. package/.agents/scripts/audit-labels-bootstrap.js +4 -4
  21. package/.agents/scripts/audit-to-stories.js +244 -19
  22. package/.agents/scripts/lib/audit-suite/checklist-threading.js +26 -3
  23. package/.agents/scripts/lib/audit-suite/dispatch-checklist.js +132 -0
  24. package/.agents/scripts/lib/audit-suite/index.js +1 -0
  25. package/.agents/scripts/lib/audit-suite/selector.js +290 -14
  26. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +2 -1
  27. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +5 -1
  28. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +23 -3
  29. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +38 -0
  30. package/.agents/scripts/lib/audit-to-stories/ledger.js +256 -0
  31. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +41 -7
  32. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +20 -2
  33. package/.agents/scripts/lib/command-header.js +1 -1
  34. package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
  35. package/.agents/scripts/lib/dynamic-workflow/performance-report-contract.js +5 -3
  36. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +56 -0
  37. package/.agents/scripts/lib/findings/route-finding.js +108 -10
  38. package/.agents/scripts/lib/observability/runtime-friction.js +62 -0
  39. package/.agents/scripts/lib/orchestration/resolve-stories.js +9 -1
  40. package/.agents/scripts/lib/orchestration/retro-proposals.js +49 -1
  41. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +73 -15
  42. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +81 -1
  43. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +1 -0
  44. package/.agents/scripts/lib/orchestration/ticket-lease.js +78 -10
  45. package/.agents/scripts/lib/orchestration/ticketing/transition.js +68 -16
  46. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +73 -0
  47. package/.agents/scripts/lib/wave-runner/live-probe.js +93 -12
  48. package/.agents/scripts/nav-registry-diff.js +449 -0
  49. package/.agents/scripts/single-story-init.js +189 -51
  50. package/.agents/scripts/stories-wave-tick.js +39 -2
  51. package/.agents/workflows/audit-accessibility.md +243 -0
  52. package/.agents/workflows/audit-architecture.md +89 -71
  53. package/.agents/workflows/audit-clean-code.md +87 -53
  54. package/.agents/workflows/audit-data-model.md +198 -0
  55. package/.agents/workflows/audit-dependencies.md +143 -28
  56. package/.agents/workflows/audit-devops.md +109 -18
  57. package/.agents/workflows/audit-documentation.md +25 -53
  58. package/.agents/workflows/audit-navigability.md +78 -22
  59. package/.agents/workflows/audit-performance.md +207 -103
  60. package/.agents/workflows/audit-privacy.md +51 -13
  61. package/.agents/workflows/audit-quality.md +71 -61
  62. package/.agents/workflows/audit-security.md +94 -71
  63. package/.agents/workflows/audit-seo.md +80 -25
  64. package/.agents/workflows/audit-sre.md +99 -66
  65. package/.agents/workflows/audit-to-stories.md +44 -5
  66. package/.agents/workflows/audit-ux-ui.md +71 -17
  67. package/.agents/workflows/deliver.md +32 -8
  68. package/.agents/workflows/helpers/audit-dual-path.md +59 -0
  69. package/.agents/workflows/helpers/audit-self-check.md +70 -0
  70. package/.agents/workflows/helpers/audit-severity-scale.md +19 -0
  71. package/.agents/workflows/helpers/deliver-story.md +25 -0
  72. package/docs/CHANGELOG.md +29 -0
  73. package/package.json +1 -1
  74. package/.agents/audit-checklists/lighthouse.md +0 -15
  75. package/.agents/schemas/audit-results.schema.json +0 -69
  76. package/.agents/workflows/audit-lighthouse.md +0 -269
@@ -481,6 +481,194 @@ export function hasWebSurface({ config, projectRoot = PROJECT_ROOT } = {}) {
481
481
  return result;
482
482
  }
483
483
 
484
+ /**
485
+ * Package names (or scope/name segments of them) that declare a **persistence
486
+ * layer** — ORMs and query builders that own a database schema. Matched
487
+ * segment-wise against the consumer's root `package.json` dependency keys, so
488
+ * `@prisma/client` matches via `prisma` and `@mikro-orm/core` via `mikro-orm`.
489
+ */
490
+ const ORM_PACKAGES = Object.freeze([
491
+ 'prisma',
492
+ 'drizzle-orm',
493
+ 'typeorm',
494
+ 'sequelize',
495
+ 'mongoose',
496
+ 'knex',
497
+ 'objection',
498
+ 'kysely',
499
+ 'mikro-orm',
500
+ 'bookshelf',
501
+ 'waterline',
502
+ ]);
503
+
504
+ /** File extensions whose tracked presence implies a persistence schema. */
505
+ const PERSISTENCE_SCHEMA_EXTENSIONS = Object.freeze(['.sql', '.prisma']);
506
+
507
+ /** Directory basenames that name a schema-migrations directory. */
508
+ const MIGRATION_DIR_NAMES = Object.freeze(['migrations', 'migrate']);
509
+
510
+ /**
511
+ * Parent directory basenames that qualify a `migrations`/`migrate` directory as
512
+ * a **database** migrations directory rather than an unrelated one (e.g.
513
+ * Mandrel's own `lib/migrations/` of framework-version upgrade steps). A
514
+ * migrations directory only counts as a persistence marker when it sits under
515
+ * one of these conventional ORM/database parents.
516
+ */
517
+ const DB_MIGRATION_PARENTS = Object.freeze([
518
+ 'db',
519
+ 'database',
520
+ 'prisma',
521
+ 'drizzle',
522
+ 'supabase',
523
+ 'sql',
524
+ ]);
525
+
526
+ /**
527
+ * Process-lifetime memo for the filesystem half of the persistence probe,
528
+ * keyed by project root — the persistence-layer analogue of
529
+ * {@link _webSurfaceFsCache}.
530
+ *
531
+ * @type {Map<string, boolean>}
532
+ */
533
+ const _persistenceFsCache = new Map();
534
+
535
+ /** Test-only: drop the memo so a fixture root can be re-probed after edits. */
536
+ export function _resetPersistenceLayerCache() {
537
+ _persistenceFsCache.clear();
538
+ }
539
+
540
+ /**
541
+ * True when the root `package.json` declares an ORM / query-builder dependency.
542
+ * Returns `null` — *indeterminate* — when the manifest exists but cannot be
543
+ * read or parsed (the caller fails open on that). A genuinely absent manifest
544
+ * (`ENOENT`) is determinate: there is no declaration, so the file scan gets its
545
+ * turn. Mirrors {@link declaresWebFramework}.
546
+ *
547
+ * @param {string} root
548
+ * @returns {boolean|null}
549
+ */
550
+ function declaresOrmDependency(root) {
551
+ let raw;
552
+ try {
553
+ raw = readFileSync(path.join(root, 'package.json'), 'utf8');
554
+ } catch (err) {
555
+ if (err?.code === 'ENOENT') return false;
556
+ return null;
557
+ }
558
+ let pkg;
559
+ try {
560
+ pkg = JSON.parse(raw);
561
+ } catch {
562
+ return null;
563
+ }
564
+ const names = [
565
+ ...Object.keys(pkg?.dependencies ?? {}),
566
+ ...Object.keys(pkg?.devDependencies ?? {}),
567
+ ];
568
+ return names.some((name) =>
569
+ name
570
+ .replace(/^@/, '')
571
+ .split('/')
572
+ .some((segment) => ORM_PACKAGES.includes(segment)),
573
+ );
574
+ }
575
+
576
+ /**
577
+ * True when a bounded scan of the source tree finds a persistence-schema
578
+ * artifact: a tracked `.sql` / `.prisma` file, or a `migrations` / `migrate`
579
+ * directory sitting under a conventional database parent (`db/`, `prisma/`, …).
580
+ *
581
+ * Unlike the web-asset scan, a truncated or completed no-find scan resolves to
582
+ * **false** (not `null`): persistence artifacts live near the repo root by
583
+ * convention, so a bounded near-root scan that does not find one is strong
584
+ * evidence of absence — and it keeps a large ORM-less repo (Mandrel itself)
585
+ * determinately not-applicable regardless of tree size. Only an unreadable
586
+ * ROOT is indeterminate (`null`), which the caller fails open on.
587
+ *
588
+ * @param {string} root
589
+ * @returns {boolean|null}
590
+ */
591
+ function scanForPersistenceArtifacts(root) {
592
+ let budget = WEB_SCAN_MAX_ENTRIES;
593
+ const queue = [{ dir: root, depth: 0 }];
594
+
595
+ while (queue.length > 0) {
596
+ const { dir, depth } = queue.shift();
597
+ let entries;
598
+ try {
599
+ entries = readdirSync(dir, { withFileTypes: true });
600
+ } catch {
601
+ if (dir === root) return null;
602
+ continue;
603
+ }
604
+
605
+ for (const entry of entries) {
606
+ if (budget-- <= 0) return false; // bounded near-root scan exhausted
607
+ if (entry.isDirectory()) {
608
+ if (WEB_SCAN_SKIP_DIRS.includes(entry.name)) continue;
609
+ if (
610
+ MIGRATION_DIR_NAMES.includes(entry.name) &&
611
+ DB_MIGRATION_PARENTS.includes(path.basename(dir))
612
+ ) {
613
+ return true;
614
+ }
615
+ if (depth + 1 <= WEB_SCAN_MAX_DEPTH) {
616
+ queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
617
+ }
618
+ continue;
619
+ }
620
+ if (PERSISTENCE_SCHEMA_EXTENSIONS.includes(path.extname(entry.name))) {
621
+ return true;
622
+ }
623
+ }
624
+ }
625
+ return false;
626
+ }
627
+
628
+ /**
629
+ * Decide whether the project has a **persistence layer** — the applicability
630
+ * predicate behind the `target: "data-model"` gate in `audit-rules.json`
631
+ * (Story #4633). The data-model lens reads ORM models, schema migrations, and
632
+ * seed data; a project with none of those has nothing for it to inspect, so the
633
+ * lens self-skips with an explicit not-applicable report rather than running to
634
+ * empty findings.
635
+ *
636
+ * Like {@link hasWebSurface}, this is derived from observables in the
637
+ * consumer's own checkout — Mandrel is materialized into other projects, so a
638
+ * consumer with a real database MUST still get the lens. A project has a
639
+ * persistence layer when ANY of these hold:
640
+ * 1. The root `package.json` declares an ORM / query-builder dependency
641
+ * (prisma / drizzle / typeorm / sequelize / mongoose / knex / …).
642
+ * 2. A bounded source scan finds a tracked `.prisma` / `.sql` schema file.
643
+ * 3. A bounded source scan finds a `migrations` / `migrate` directory under a
644
+ * conventional database parent (`db/`, `prisma/`, `drizzle/`, …).
645
+ *
646
+ * **Fail direction: OPEN.** When the ORM-dependency signal is indeterminate —
647
+ * an unparseable `package.json`, an unreadable root — the project is treated as
648
+ * having a persistence layer, for the same reason as the web probe: a wasted
649
+ * lens run is recoverable, silently dropped coverage is not. A determinate
650
+ * "no ORM dependency + no schema artifact found" (Mandrel's own shape) is a
651
+ * clean not-applicable.
652
+ *
653
+ * @param {{ config?: object|null, projectRoot?: string }} [params]
654
+ * @returns {boolean}
655
+ */
656
+ export function hasPersistenceLayer({ projectRoot = PROJECT_ROOT } = {}) {
657
+ if (_persistenceFsCache.has(projectRoot)) {
658
+ return _persistenceFsCache.get(projectRoot);
659
+ }
660
+
661
+ const declared = declaresOrmDependency(projectRoot);
662
+ // `null` is indeterminate, not false — fail open.
663
+ const result =
664
+ declared === null || declared === true
665
+ ? true
666
+ : scanForPersistenceArtifacts(projectRoot) !== false;
667
+
668
+ _persistenceFsCache.set(projectRoot, result);
669
+ return result;
670
+ }
671
+
484
672
  /**
485
673
  * Test a single filename against a single glob pattern using the project's
486
674
  * configured matcher semantics (`picomatch` with `dot: true`). Exported so
@@ -500,6 +688,80 @@ export function matchesAnyFilePattern(patterns, files) {
500
688
  return files.some((file) => matchers.some((m) => m(file)));
501
689
  }
502
690
 
691
+ /** Extensions the sibling-test predicate treats as production source code. */
692
+ const SOURCE_CODE_EXTENSIONS = Object.freeze([
693
+ '.js',
694
+ '.mjs',
695
+ '.cjs',
696
+ '.jsx',
697
+ '.ts',
698
+ '.tsx',
699
+ ]);
700
+
701
+ // biome-ignore lint/complexity/useRegexLiterals: constructor form keeps the MI walker able to score this module.
702
+ const TEST_FILE_RE = new RegExp(String.raw`\.(test|spec)\.[cm]?[jt]sx?$`);
703
+ // biome-ignore lint/complexity/useRegexLiterals: constructor form keeps the MI walker able to score this module.
704
+ const TEST_DIR_RE = new RegExp(
705
+ String.raw`(^|/)(tests?|__tests__|spec|e2e|__mocks__)(/|$)`,
706
+ );
707
+ // biome-ignore lint/complexity/useRegexLiterals: constructor form keeps the MI walker able to score this module.
708
+ const CODE_EXT_RE = new RegExp(String.raw`\.[cm]?[jt]sx?$`);
709
+
710
+ /**
711
+ * True when `file` is a test/spec file — either by the `.test.` / `.spec.`
712
+ * infix or by living under a conventional test directory.
713
+ *
714
+ * @param {string} file
715
+ * @returns {boolean}
716
+ */
717
+ function isTestFile(file) {
718
+ return TEST_FILE_RE.test(file) || TEST_DIR_RE.test(file);
719
+ }
720
+
721
+ /**
722
+ * The sibling stem of a path: its basename with any `.test` / `.spec` infix and
723
+ * the code extension stripped (`src/foo.test.js` and `src/foo.js` both → `foo`).
724
+ *
725
+ * @param {string} file
726
+ * @returns {string}
727
+ */
728
+ function stemOf(file) {
729
+ const base = file.split('/').pop() ?? file;
730
+ return base.replace(TEST_FILE_RE, '').replace(CODE_EXT_RE, '');
731
+ }
732
+
733
+ /**
734
+ * The coverage-gap routing predicate behind the `sourceWithoutSiblingTest`
735
+ * trigger (Story #4628): a change set warrants the `audit-quality` lens when it
736
+ * touches at least one **production source** file whose **sibling test is not
737
+ * also in the change set**. This flips the historical (backwards) trigger that
738
+ * fired the coverage-gap detector on test-file changes — a diff that only edits
739
+ * tests has, if anything, *more* coverage, not less; the risk lives in source
740
+ * that shipped without a matching test.
741
+ *
742
+ * A source file's sibling is matched by stem (`src/foo.js` ↔ any changed
743
+ * `foo.test.js` / `foo.spec.js`, regardless of directory). Test-only diffs,
744
+ * doc/config-only diffs, and source diffs whose every file has a sibling test
745
+ * in the same change set all return `false` and route no quality lens.
746
+ *
747
+ * Pure over its input; no git, no disk.
748
+ *
749
+ * @param {string[]|null|undefined} changedFiles
750
+ * @returns {boolean}
751
+ */
752
+ export function changeSetLacksSiblingTest(changedFiles) {
753
+ const files = (Array.isArray(changedFiles) ? changedFiles : []).filter(
754
+ (f) => typeof f === 'string' && f.length > 0,
755
+ );
756
+ const testStems = new Set(
757
+ files.filter((f) => isTestFile(f)).map((f) => stemOf(f)),
758
+ );
759
+ const sources = files.filter(
760
+ (f) => !isTestFile(f) && SOURCE_CODE_EXTENSIONS.includes(path.extname(f)),
761
+ );
762
+ return sources.some((src) => !testStems.has(stemOf(src)));
763
+ }
764
+
503
765
  /**
504
766
  * Filter audits based on logic in audit-rules.json (validated against
505
767
  * audit-rules.schema.json).
@@ -561,6 +823,7 @@ export async function selectAudits({
561
823
  gitTimeoutMsOverride,
562
824
  gateModeOpts,
563
825
  hasWebSurfaceFn = hasWebSurface,
826
+ hasPersistenceLayerFn = hasPersistenceLayer,
564
827
  }) {
565
828
  const config = resolveConfig();
566
829
  const timeoutMs = gitTimeoutMsOverride ?? DEFAULT_GIT_TIMEOUT_MS;
@@ -667,15 +930,21 @@ export async function selectAudits({
667
930
 
668
931
  const selectedAudits = [];
669
932
 
670
- // Resolved at most once per call, and only if a `target: "web"` lens
671
- // actually clears its gate — a Node-only project must not pay a filesystem
672
- // scan on a roster with no web lens in it.
673
- let webSurfaceMemo = null;
674
- const projectIsWebCapable = () => {
675
- if (webSurfaceMemo === null) {
676
- webSurfaceMemo = hasWebSurfaceFn({ config });
933
+ // Applicability probes, resolved at most once per target per call, and only
934
+ // if a lens declaring that target actually clears its gate — a Node-only
935
+ // project must not pay a filesystem scan on a roster with no `web` lens in
936
+ // it, nor a DB-less project pay one for `data-model`.
937
+ const targetProbes = {
938
+ web: hasWebSurfaceFn,
939
+ 'data-model': hasPersistenceLayerFn,
940
+ };
941
+ const targetApplicabilityMemo = new Map();
942
+ const projectSupportsTarget = (target) => {
943
+ if (!targetApplicabilityMemo.has(target)) {
944
+ const probe = targetProbes[target];
945
+ targetApplicabilityMemo.set(target, probe ? probe({ config }) : true);
677
946
  }
678
- return webSurfaceMemo;
947
+ return targetApplicabilityMemo.get(target);
679
948
  };
680
949
 
681
950
  for (const [auditName, ruleOpts] of Object.entries(rulesData.audits || {})) {
@@ -684,15 +953,16 @@ export async function selectAudits({
684
953
  const gateMatch = triggers.gates?.includes(gate);
685
954
  if (!gateMatch) continue;
686
955
 
687
- // Target-applicability gate (#4579). A lens declaring `target: "web"`
688
- // has nothing to read on a project with no web surface, yet still
956
+ // Target-applicability gate (#4579, #4633). A lens declaring a `target`
957
+ // has nothing to read on a project lacking that surface, yet still
689
958
  // whole-word-matches ordinary prose — `audit-seo` fires on the `meta`
690
959
  // inside every Story body's `<!-- meta: {...} -->` machine comment. The
691
960
  // roster's own instruction is that the host MUST walk every listed lens,
692
961
  // so an inapplicable entry is not just wasted spend: it teaches operators
693
- // to ignore the MUST. An absent `target` means "always applicable", so no
694
- // existing lens changes behaviour.
695
- if (ruleOpts.target === 'web' && !projectIsWebCapable()) continue;
962
+ // to ignore the MUST. An absent `target` (or `any`) means "always
963
+ // applicable", so no existing lens changes behaviour.
964
+ const { target } = ruleOpts;
965
+ if (target && target !== 'any' && !projectSupportsTarget(target)) continue;
696
966
 
697
967
  const keywords = triggers.keywords || [];
698
968
  let keywordMatch = false;
@@ -711,7 +981,13 @@ export async function selectAudits({
711
981
  changedFiles,
712
982
  );
713
983
 
714
- if (keywordMatch || fileMatch) {
984
+ // Coverage-gap routing (#4628): a lens declaring `sourceWithoutSiblingTest`
985
+ // fires when the change set touches source lacking a sibling test.
986
+ const siblingMatch =
987
+ triggers.sourceWithoutSiblingTest === true &&
988
+ changeSetLacksSiblingTest(changedFiles);
989
+
990
+ if (keywordMatch || fileMatch || siblingMatch) {
715
991
  selectedAudits.push(auditName);
716
992
  }
717
993
  }
@@ -25,12 +25,13 @@
25
25
  * @type {ReadonlyArray<string>}
26
26
  */
27
27
  export const AUDIT_LENSES = Object.freeze([
28
+ 'accessibility',
28
29
  'architecture',
29
30
  'clean-code',
31
+ 'data-model',
30
32
  'dependencies',
31
33
  'devops',
32
34
  'documentation',
33
- 'lighthouse',
34
35
  'navigability',
35
36
  'performance',
36
37
  'privacy',
@@ -24,7 +24,10 @@
24
24
 
25
25
  import { serialize } from '../story-body/story-body.js';
26
26
  import { auditLabelsForFindings } from './audit-lenses.js';
27
- import { renderFingerprintFooter } from './finding-adapter.js';
27
+ import {
28
+ renderFingerprintFooter,
29
+ renderSemanticKeyFooter,
30
+ } from './finding-adapter.js';
28
31
 
29
32
  const STATIC_LABELS = Object.freeze(['type::story', 'agent::ready']);
30
33
 
@@ -239,6 +242,7 @@ export function buildStoryBody({ group, edges = [] }) {
239
242
  contextLinksFromGroup(group),
240
243
  '',
241
244
  renderFingerprintFooter(group.findings),
245
+ renderSemanticKeyFooter(group.findings),
242
246
  ].join('\n');
243
247
 
244
248
  return { title, body, labels: labelsForGroup(group) };
@@ -16,6 +16,11 @@
16
16
  * The GitHub lookup is delegated to a `provider` port the caller injects,
17
17
  * exposing `findIssuesByFingerprint(sha)` → `{ number, state, body }[]`. The
18
18
  * port is adapted into the `searchIssues` shape the shared helper expects.
19
+ * When the caller ALSO injects a `searchCandidates(finding)` port (production
20
+ * wires it to `semantic-issue-search.js`), routing runs the meaning-first
21
+ * Stage-1 pass and opts into location-based semantic-key confirmation so a
22
+ * reworded finding at an unchanged location still dedupes against its Issue
23
+ * (Story #4626).
19
24
  *
20
25
  * Pure orchestration: this module performs no network I/O itself.
21
26
  */
@@ -35,9 +40,17 @@ import { toCanonicalFinding } from './finding-adapter.js';
35
40
  * @param {object} params
36
41
  * @param {Array<object>} params.groups — output of `groupFindings`.
37
42
  * @param {{ findIssuesByFingerprint: (sha: string) => Promise<Array<{ number: number, state: string, body?: string }>> }} params.provider
43
+ * @param {(finding: object) => Promise<Array<{ number: number, state: string, title?: string, body?: string }>>} [params.searchCandidates]
44
+ * Optional meaning-first candidate search (production: `semantic-issue-search.js`).
45
+ * When supplied, routing runs the Stage-1 semantic pass and opts into
46
+ * location-based semantic-key confirmation.
38
47
  * @returns {Promise<{ classifications: GroupClassification[], summary: { create: number, skipOpen: number, skipReoccurring: number } }>}
39
48
  */
40
- export async function classifyGroupsAgainstGitHub({ groups, provider }) {
49
+ export async function classifyGroupsAgainstGitHub({
50
+ groups,
51
+ provider,
52
+ searchCandidates,
53
+ }) {
41
54
  if (!Array.isArray(groups)) {
42
55
  throw new Error('classifyGroupsAgainstGitHub: groups must be an array');
43
56
  }
@@ -52,6 +65,9 @@ export async function classifyGroupsAgainstGitHub({ groups, provider }) {
52
65
  // projection, which equals the sha the group already carries (both come
53
66
  // from the same `toCanonicalFinding` projection).
54
67
  const searchIssues = (sha) => provider.findIssuesByFingerprint(sha);
68
+ const semanticPort =
69
+ typeof searchCandidates === 'function' ? searchCandidates : undefined;
70
+ const routeOptions = { semanticKeyConfirm: Boolean(semanticPort) };
55
71
 
56
72
  const classifications = [];
57
73
  const summary = { create: 0, skipOpen: 0, skipReoccurring: 0 };
@@ -68,9 +84,13 @@ export async function classifyGroupsAgainstGitHub({ groups, provider }) {
68
84
  const sha = finding?.fingerprint?.full;
69
85
  if (typeof sha !== 'string' || sha.length !== 40) continue;
70
86
 
87
+ const canonical = toCanonicalFinding(finding);
71
88
  const { decision, matchedIssue, fingerprint } = await routeFinding(
72
- toCanonicalFinding(finding),
73
- { searchIssues },
89
+ canonical,
90
+ semanticPort
91
+ ? { searchIssues, searchCandidates: () => semanticPort(canonical) }
92
+ : { searchIssues },
93
+ routeOptions,
74
94
  );
75
95
 
76
96
  if (decision === 'new') continue;
@@ -16,6 +16,8 @@
16
16
  import {
17
17
  fingerprintFinding,
18
18
  fingerprintFooter,
19
+ semanticKeyFooter,
20
+ semanticKeyFor,
19
21
  } from '../findings/route-finding.js';
20
22
 
21
23
  /**
@@ -55,6 +57,18 @@ export function fingerprintAuditFinding(finding) {
55
57
  return fingerprintFinding(toCanonicalFinding(finding));
56
58
  }
57
59
 
60
+ /**
61
+ * Compute the location-based semantic key for a single audit finding via the
62
+ * shared helper. Stable across a reworded title; used to confirm a dedup
63
+ * match when the fingerprint has drifted (Story #4626).
64
+ *
65
+ * @param {object} finding
66
+ * @returns {string}
67
+ */
68
+ export function semanticKeyForAuditFinding(finding) {
69
+ return semanticKeyFor(toCanonicalFinding(finding));
70
+ }
71
+
58
72
  /**
59
73
  * Stamp every audit finding with its shared-helper fingerprint and return a
60
74
  * new array. Mirrors the legacy `withFingerprints` contract so downstream
@@ -91,3 +105,27 @@ export function renderFingerprintFooter(findings) {
91
105
  .filter((sha) => typeof sha === 'string' && sha.length > 0);
92
106
  return fingerprintFooter(shas);
93
107
  }
108
+
109
+ /**
110
+ * Render the location-based semantic-key footer for a group of findings, via
111
+ * the shared helper's single footer renderer. Stamped alongside the
112
+ * fingerprint footer so a later reworded finding at the same location still
113
+ * confirms a dedup match (Story #4626). Findings do not need a precomputed
114
+ * key — it is derived from each finding's canonical projection here.
115
+ *
116
+ * @param {Array<object>} findings
117
+ * @returns {string}
118
+ */
119
+ export function renderSemanticKeyFooter(findings) {
120
+ if (!Array.isArray(findings)) {
121
+ throw new Error('renderSemanticKeyFooter: findings must be an array');
122
+ }
123
+ const keys = [
124
+ ...new Set(
125
+ findings
126
+ .map((f) => semanticKeyForAuditFinding(f))
127
+ .filter((k) => typeof k === 'string' && k.length > 0),
128
+ ),
129
+ ];
130
+ return semanticKeyFooter(keys);
131
+ }