mandrel 2.4.0 → 2.6.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/.agents/audit-checklists/accessibility.md +29 -0
- package/.agents/audit-checklists/architecture.md +4 -5
- package/.agents/audit-checklists/clean-code.md +10 -0
- package/.agents/audit-checklists/data-model.md +22 -0
- package/.agents/audit-checklists/dependencies.md +11 -2
- package/.agents/audit-checklists/devops.md +4 -0
- package/.agents/audit-checklists/navigability.md +3 -0
- package/.agents/audit-checklists/performance.md +8 -11
- package/.agents/audit-checklists/privacy.md +3 -4
- package/.agents/audit-checklists/quality.md +2 -0
- package/.agents/audit-checklists/security.md +4 -5
- package/.agents/audit-checklists/seo.md +7 -1
- package/.agents/audit-checklists/sre.md +14 -12
- package/.agents/audit-checklists/ux-ui.md +4 -0
- package/.agents/docs/configuration.md +3 -0
- package/.agents/docs/workflows.md +4 -3
- package/.agents/schemas/agentrc.schema.json +17 -0
- package/.agents/schemas/audit-rules.json +134 -19
- package/.agents/schemas/audit-rules.schema.json +6 -2
- package/.agents/scripts/audit-labels-bootstrap.js +4 -4
- package/.agents/scripts/audit-to-stories.js +244 -19
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +26 -3
- package/.agents/scripts/lib/audit-suite/dispatch-checklist.js +132 -0
- package/.agents/scripts/lib/audit-suite/index.js +1 -0
- package/.agents/scripts/lib/audit-suite/selector.js +290 -14
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +2 -1
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +5 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +23 -3
- package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +38 -0
- package/.agents/scripts/lib/audit-to-stories/ledger.js +256 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +41 -7
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +20 -2
- package/.agents/scripts/lib/command-header.js +1 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
- package/.agents/scripts/lib/dynamic-workflow/performance-report-contract.js +5 -3
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +56 -0
- package/.agents/scripts/lib/findings/route-finding.js +108 -10
- package/.agents/scripts/lib/observability/runtime-friction.js +137 -6
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +5 -19
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +11 -0
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +81 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +1 -0
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +53 -13
- package/.agents/scripts/nav-registry-diff.js +449 -0
- package/.agents/workflows/audit-accessibility.md +243 -0
- package/.agents/workflows/audit-architecture.md +89 -71
- package/.agents/workflows/audit-clean-code.md +87 -53
- package/.agents/workflows/audit-data-model.md +198 -0
- package/.agents/workflows/audit-dependencies.md +143 -28
- package/.agents/workflows/audit-devops.md +109 -18
- package/.agents/workflows/audit-documentation.md +25 -53
- package/.agents/workflows/audit-navigability.md +78 -22
- package/.agents/workflows/audit-performance.md +207 -103
- package/.agents/workflows/audit-privacy.md +51 -13
- package/.agents/workflows/audit-quality.md +71 -61
- package/.agents/workflows/audit-security.md +94 -71
- package/.agents/workflows/audit-seo.md +80 -25
- package/.agents/workflows/audit-sre.md +99 -66
- package/.agents/workflows/audit-to-stories.md +44 -5
- package/.agents/workflows/audit-ux-ui.md +71 -17
- package/.agents/workflows/helpers/audit-dual-path.md +59 -0
- package/.agents/workflows/helpers/audit-self-check.md +70 -0
- package/.agents/workflows/helpers/audit-severity-scale.md +19 -0
- package/.agents/workflows/helpers/deliver-story.md +25 -0
- package/docs/CHANGELOG.md +23 -0
- package/package.json +1 -1
- package/.agents/audit-checklists/lighthouse.md +0 -15
- package/.agents/schemas/audit-results.schema.json +0 -69
- 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
|
-
//
|
|
671
|
-
// actually clears its gate — a Node-only
|
|
672
|
-
// scan on a roster with no web lens in
|
|
673
|
-
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
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
|
|
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
|
|
688
|
-
// has nothing to read on a project
|
|
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
|
|
694
|
-
// existing lens changes behaviour.
|
|
695
|
-
|
|
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
|
-
|
|
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 {
|
|
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({
|
|
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
|
-
|
|
73
|
-
|
|
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
|
+
}
|