@promptci/core 0.1.0 → 0.2.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.
Files changed (152) hide show
  1. package/dist/action-pinning.d.ts +37 -0
  2. package/dist/action-pinning.d.ts.map +1 -0
  3. package/dist/action-pinning.js +277 -0
  4. package/dist/action-pinning.js.map +1 -0
  5. package/dist/agent-practices.d.ts +1 -1
  6. package/dist/agent-practices.d.ts.map +1 -1
  7. package/dist/agent-practices.js +107 -43
  8. package/dist/agent-practices.js.map +1 -1
  9. package/dist/ai-config.d.ts +42 -3
  10. package/dist/ai-config.d.ts.map +1 -1
  11. package/dist/ai-config.js +210 -12
  12. package/dist/ai-config.js.map +1 -1
  13. package/dist/alias-files.d.ts +33 -0
  14. package/dist/alias-files.d.ts.map +1 -0
  15. package/dist/alias-files.js +60 -0
  16. package/dist/alias-files.js.map +1 -0
  17. package/dist/buried-critical.d.ts.map +1 -1
  18. package/dist/buried-critical.js +4 -9
  19. package/dist/buried-critical.js.map +1 -1
  20. package/dist/canonical-owner.d.ts.map +1 -1
  21. package/dist/canonical-owner.js +45 -25
  22. package/dist/canonical-owner.js.map +1 -1
  23. package/dist/ci-alignment.d.ts.map +1 -1
  24. package/dist/ci-alignment.js +3 -2
  25. package/dist/ci-alignment.js.map +1 -1
  26. package/dist/command-validity.d.ts +16 -0
  27. package/dist/command-validity.d.ts.map +1 -1
  28. package/dist/command-validity.js +337 -63
  29. package/dist/command-validity.js.map +1 -1
  30. package/dist/conflicts.d.ts.map +1 -1
  31. package/dist/conflicts.js +3 -2
  32. package/dist/conflicts.js.map +1 -1
  33. package/dist/context-bloat.d.ts +1 -1
  34. package/dist/context-bloat.d.ts.map +1 -1
  35. package/dist/context-bloat.js +18 -7
  36. package/dist/context-bloat.js.map +1 -1
  37. package/dist/context-optimizer.d.ts.map +1 -1
  38. package/dist/context-optimizer.js +5 -2
  39. package/dist/context-optimizer.js.map +1 -1
  40. package/dist/custom-rules.d.ts.map +1 -1
  41. package/dist/custom-rules.js +6 -1
  42. package/dist/custom-rules.js.map +1 -1
  43. package/dist/dead-references.d.ts +16 -2
  44. package/dist/dead-references.d.ts.map +1 -1
  45. package/dist/dead-references.js +635 -77
  46. package/dist/dead-references.js.map +1 -1
  47. package/dist/dependency-staleness.d.ts +35 -0
  48. package/dist/dependency-staleness.d.ts.map +1 -0
  49. package/dist/dependency-staleness.js +1001 -0
  50. package/dist/dependency-staleness.js.map +1 -0
  51. package/dist/detectors.d.ts.map +1 -1
  52. package/dist/detectors.js +31 -6
  53. package/dist/detectors.js.map +1 -1
  54. package/dist/duplicates.d.ts.map +1 -1
  55. package/dist/duplicates.js +5 -4
  56. package/dist/duplicates.js.map +1 -1
  57. package/dist/evidence.d.ts +11 -0
  58. package/dist/evidence.d.ts.map +1 -1
  59. package/dist/evidence.js +40 -0
  60. package/dist/evidence.js.map +1 -1
  61. package/dist/finding-id.d.ts +21 -0
  62. package/dist/finding-id.d.ts.map +1 -0
  63. package/dist/finding-id.js +31 -0
  64. package/dist/finding-id.js.map +1 -0
  65. package/dist/frameworks/dotnet.js +2 -2
  66. package/dist/frameworks/dotnet.js.map +1 -1
  67. package/dist/frameworks/rust.d.ts.map +1 -1
  68. package/dist/frameworks/rust.js +2 -1
  69. package/dist/frameworks/rust.js.map +1 -1
  70. package/dist/git-symlinks.d.ts +30 -0
  71. package/dist/git-symlinks.d.ts.map +1 -0
  72. package/dist/git-symlinks.js +161 -0
  73. package/dist/git-symlinks.js.map +1 -0
  74. package/dist/gitignore.d.ts +22 -0
  75. package/dist/gitignore.d.ts.map +1 -0
  76. package/dist/gitignore.js +157 -0
  77. package/dist/gitignore.js.map +1 -0
  78. package/dist/health-score.d.ts.map +1 -1
  79. package/dist/health-score.js +34 -5
  80. package/dist/health-score.js.map +1 -1
  81. package/dist/index.d.ts +9 -3
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +5 -2
  84. package/dist/index.js.map +1 -1
  85. package/dist/makefile.d.ts +37 -0
  86. package/dist/makefile.d.ts.map +1 -0
  87. package/dist/makefile.js +156 -0
  88. package/dist/makefile.js.map +1 -0
  89. package/dist/manifest-consistency.d.ts.map +1 -1
  90. package/dist/manifest-consistency.js +52 -8
  91. package/dist/manifest-consistency.js.map +1 -1
  92. package/dist/negative-instructions.d.ts.map +1 -1
  93. package/dist/negative-instructions.js +4 -9
  94. package/dist/negative-instructions.js.map +1 -1
  95. package/dist/path-containment.d.ts +29 -0
  96. package/dist/path-containment.d.ts.map +1 -1
  97. package/dist/path-containment.js +70 -0
  98. package/dist/path-containment.js.map +1 -1
  99. package/dist/prompt-cache.d.ts.map +1 -1
  100. package/dist/prompt-cache.js +8 -6
  101. package/dist/prompt-cache.js.map +1 -1
  102. package/dist/repo-context.d.ts +53 -0
  103. package/dist/repo-context.d.ts.map +1 -1
  104. package/dist/repo-context.js +134 -11
  105. package/dist/repo-context.js.map +1 -1
  106. package/dist/report.d.ts +7 -0
  107. package/dist/report.d.ts.map +1 -1
  108. package/dist/report.js +36 -7
  109. package/dist/report.js.map +1 -1
  110. package/dist/scan.d.ts.map +1 -1
  111. package/dist/scan.js +41 -2
  112. package/dist/scan.js.map +1 -1
  113. package/dist/scanner.d.ts +49 -1
  114. package/dist/scanner.d.ts.map +1 -1
  115. package/dist/scanner.js +323 -38
  116. package/dist/scanner.js.map +1 -1
  117. package/dist/security-pack.d.ts.map +1 -1
  118. package/dist/security-pack.js +6 -4
  119. package/dist/security-pack.js.map +1 -1
  120. package/dist/skill-supply-chain.d.ts +132 -0
  121. package/dist/skill-supply-chain.d.ts.map +1 -0
  122. package/dist/skill-supply-chain.js +2954 -0
  123. package/dist/skill-supply-chain.js.map +1 -0
  124. package/dist/skills-detector.d.ts +7 -3
  125. package/dist/skills-detector.d.ts.map +1 -1
  126. package/dist/skills-detector.js +98 -22
  127. package/dist/skills-detector.js.map +1 -1
  128. package/dist/stale-instructions.d.ts.map +1 -1
  129. package/dist/stale-instructions.js +2 -1
  130. package/dist/stale-instructions.js.map +1 -1
  131. package/dist/suppression.d.ts +2 -0
  132. package/dist/suppression.d.ts.map +1 -1
  133. package/dist/suppression.js +5 -2
  134. package/dist/suppression.js.map +1 -1
  135. package/dist/types.d.ts +35 -2
  136. package/dist/types.d.ts.map +1 -1
  137. package/dist/types.js +22 -0
  138. package/dist/types.js.map +1 -1
  139. package/dist/vague-guidance.d.ts.map +1 -1
  140. package/dist/vague-guidance.js +2 -1
  141. package/dist/vague-guidance.js.map +1 -1
  142. package/dist/windsurf-detector.d.ts.map +1 -1
  143. package/dist/windsurf-detector.js +15 -6
  144. package/dist/windsurf-detector.js.map +1 -1
  145. package/dist/within-section-dedup.d.ts.map +1 -1
  146. package/dist/within-section-dedup.js +4 -9
  147. package/dist/within-section-dedup.js.map +1 -1
  148. package/dist/workspace-manifests.d.ts +80 -0
  149. package/dist/workspace-manifests.d.ts.map +1 -0
  150. package/dist/workspace-manifests.js +333 -0
  151. package/dist/workspace-manifests.js.map +1 -0
  152. package/package.json +1 -1
@@ -0,0 +1,37 @@
1
+ import type { RepoContext } from './repo-context.js';
2
+ import type { PromptCiIssue } from './types.js';
3
+ export type ActionUse = {
4
+ /** The `uses:` value as written (quotes and trailing comment stripped). */
5
+ ref: string;
6
+ /** 1-based line number. */
7
+ line: number;
8
+ };
9
+ export type UnpinnedUse = ActionUse & {
10
+ /**
11
+ * False when the ref's text does not match the plain `owner/repo[/path]@ref`
12
+ * shape. Such a ref is still reported, but its raw text is never
13
+ * interpolated into titles, evidence, or the recommended `gh` command — it
14
+ * came from the scanned repo and may carry shell or Markdown metacharacters.
15
+ */
16
+ displayable: boolean;
17
+ /** `owner/repo[/path]` part before the `@` (only meaningful when displayable). */
18
+ action: string;
19
+ /** Git ref after the `@` (only meaningful when displayable). */
20
+ version: string;
21
+ owner: string;
22
+ };
23
+ /**
24
+ * Every step/job-level `uses:` reference in a workflow or composite-action
25
+ * file, in line order. Skipped: comments, lines inside block scalars (a
26
+ * `run: |` script that prints `uses: foo@v1`), and `uses` keys nested under
27
+ * data mappings such as `with:`.
28
+ */
29
+ export declare function extractActionUses(content: string): ActionUse[];
30
+ /**
31
+ * Classify one `uses:` value. Returns the parts for a remote ref that should
32
+ * be pinned but is not, or `undefined` when it is pinned, local, a container,
33
+ * or an expression.
34
+ */
35
+ export declare function unpinnedUse(use: ActionUse): UnpinnedUse | undefined;
36
+ export declare function detectActionPinning(context: RepoContext): PromptCiIssue[];
37
+ //# sourceMappingURL=action-pinning.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"action-pinning.d.ts","sourceRoot":"","sources":["../src/action-pinning.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAkB,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAwDhD,MAAM,MAAM,SAAS,GAAG;IACtB,2EAA2E;IAC3E,GAAG,EAAE,MAAM,CAAC;IACZ,2BAA2B;IAC3B,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG;IACpC;;;;;OAKG;IACH,WAAW,EAAE,OAAO,CAAC;IACrB,kFAAkF;IAClF,MAAM,EAAE,MAAM,CAAC;IACf,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AA6DF;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,CA6C9D;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,SAAS,GAAG,WAAW,GAAG,SAAS,CAoBnE;AA6ED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,WAAW,GAAG,aAAa,EAAE,CAkBzE"}
@@ -0,0 +1,277 @@
1
+ import * as crypto from 'node:crypto';
2
+ import * as path from 'node:path';
3
+ /**
4
+ * GitHub Actions pinning detector (detect-only).
5
+ *
6
+ * Flags `uses:` references in `.github/workflows/*.yml` and in composite
7
+ * actions (`action.yml` at the repo root or under `.github/actions/`) that
8
+ * point at a tag or branch instead of a full commit SHA. A tag is a mutable
9
+ * pointer: whoever can push to the action's repository can move it, and every
10
+ * workflow referencing it runs the new code on its next run. A SHA cannot be
11
+ * repointed.
12
+ *
13
+ * Deliberately DETECT-ONLY: there is no fix recipe and `autoApplySafe` is never
14
+ * set. Resolving a tag to its SHA needs a network call, and this package makes
15
+ * none from detector code — pinning is left to the user (or Dependabot /
16
+ * Renovate, which keep a pinned SHA and its version comment in step).
17
+ *
18
+ * Exempt: local actions (`./path`) and container actions (`docker://…`), which
19
+ * have no git ref to pin. These files are read from `workflows.sources`, never
20
+ * from `context.files`, so they are never treated as instruction files.
21
+ *
22
+ * Deterministic and offline: line-oriented parsing of the YAML text, no clock,
23
+ * no network.
24
+ */
25
+ /** A full git commit id: 40-hex SHA-1, or 64-hex for SHA-256 repositories. */
26
+ const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
27
+ /** `owner/repo` or `owner/repo/sub/path` — the only action shape shown verbatim. */
28
+ const ACTION_RE = /^[\w.-]+\/[\w.-]+(?:\/[\w./-]+)?$/;
29
+ /** A plausible tag/branch name — the only version shape shown verbatim. */
30
+ const VERSION_RE = /^[\w./+-]+$/;
31
+ /**
32
+ * Owners whose actions GitHub itself maintains. An unpinned first-party ref is
33
+ * still a mutable pointer, but the account holding it is GitHub's own, so it
34
+ * is reported one severity lower than a third-party ref.
35
+ */
36
+ const FIRST_PARTY_OWNERS = new Set(['actions', 'github']);
37
+ /** A `key: value` mapping line, optionally the first key of a list item. */
38
+ const KEY_LINE_RE = /^(\s*)(-\s+)?([\w.-]+)\s*:(?:\s+(.*))?$/;
39
+ /** A list item whose value is a flow mapping: `- { uses: x@v1, with: {...} }`. */
40
+ const FLOW_ITEM_RE = /^\s*-\s+(\{.*)$/;
41
+ /** A block scalar indicator (`|`, `>-`, `|2+`), optionally followed by a comment. */
42
+ const BLOCK_SCALAR_RE = /^[|>][+-]?\d*\s*(?:#.*)?$/;
43
+ /**
44
+ * Keys whose block-mapping values are free-form data, not steps. A `uses:` key
45
+ * nested under them (e.g. an input literally named `uses`) is not a step.
46
+ */
47
+ const DATA_MAPPING_KEYS = new Set(['with', 'env', 'secrets', 'inputs', 'outputs']);
48
+ /** Strip a trailing ` # comment` and surrounding quotes from a scalar value. */
49
+ function scalarValue(raw) {
50
+ let value = raw.trim();
51
+ const quote = value[0];
52
+ if (quote === '"' || quote === "'") {
53
+ const end = value.indexOf(quote, 1);
54
+ return end > 0 ? value.slice(1, end) : value.slice(1);
55
+ }
56
+ const comment = value.search(/(?:^|\s)#/);
57
+ if (comment >= 0)
58
+ value = value.slice(0, comment);
59
+ return value.trim();
60
+ }
61
+ /**
62
+ * The value of a top-level `uses` key in a single-line flow mapping, or
63
+ * undefined. Nested maps (`with: { uses: … }`) are depth > 1 and ignored.
64
+ */
65
+ function flowMappingUses(flow) {
66
+ const entries = [];
67
+ let depth = 0;
68
+ let quote = '';
69
+ let current = '';
70
+ for (const c of flow) {
71
+ if (quote) {
72
+ if (c === quote)
73
+ quote = '';
74
+ if (depth === 1)
75
+ current += c;
76
+ continue;
77
+ }
78
+ if (c === '"' || c === "'") {
79
+ quote = c;
80
+ if (depth === 1)
81
+ current += c;
82
+ continue;
83
+ }
84
+ if (c === '{' || c === '[') {
85
+ depth++;
86
+ if (depth > 1)
87
+ current += c;
88
+ continue;
89
+ }
90
+ if (c === '}' || c === ']') {
91
+ depth--;
92
+ if (depth === 0)
93
+ break;
94
+ current += c;
95
+ continue;
96
+ }
97
+ if (c === ',' && depth === 1) {
98
+ entries.push(current);
99
+ current = '';
100
+ continue;
101
+ }
102
+ if (depth >= 1)
103
+ current += c;
104
+ }
105
+ entries.push(current);
106
+ for (const entry of entries) {
107
+ const m = /^\s*uses\s*:\s*(.*)$/.exec(entry);
108
+ if (m)
109
+ return scalarValue(m[1]);
110
+ }
111
+ return undefined;
112
+ }
113
+ /**
114
+ * Every step/job-level `uses:` reference in a workflow or composite-action
115
+ * file, in line order. Skipped: comments, lines inside block scalars (a
116
+ * `run: |` script that prints `uses: foo@v1`), and `uses` keys nested under
117
+ * data mappings such as `with:`.
118
+ */
119
+ export function extractActionUses(content) {
120
+ const lines = content.split(/\r?\n/);
121
+ const uses = [];
122
+ // Lines indented deeper than this column belong to a block scalar or data
123
+ // mapping and are skipped; -1 = not inside one.
124
+ let skipDeeperThan = -1;
125
+ for (let i = 0; i < lines.length; i++) {
126
+ const line = lines[i];
127
+ if (line.trim() === '')
128
+ continue;
129
+ const indent = /^\s*/.exec(line)[0].length;
130
+ if (skipDeeperThan >= 0) {
131
+ if (indent > skipDeeperThan)
132
+ continue;
133
+ skipDeeperThan = -1;
134
+ }
135
+ if (/^\s*#/.test(line))
136
+ continue;
137
+ const flow = FLOW_ITEM_RE.exec(line);
138
+ if (flow) {
139
+ const ref = flowMappingUses(flow[1]);
140
+ if (ref)
141
+ uses.push({ ref, line: i + 1 });
142
+ continue;
143
+ }
144
+ const kv = KEY_LINE_RE.exec(line);
145
+ if (!kv)
146
+ continue;
147
+ const keyCol = kv[1].length + (kv[2]?.length ?? 0);
148
+ const key = kv[3];
149
+ const rawValue = kv[4] ?? '';
150
+ const value = rawValue.trim();
151
+ if (key === 'uses') {
152
+ const ref = scalarValue(rawValue);
153
+ if (ref)
154
+ uses.push({ ref, line: i + 1 });
155
+ continue;
156
+ }
157
+ if (BLOCK_SCALAR_RE.test(value)) {
158
+ skipDeeperThan = keyCol;
159
+ continue;
160
+ }
161
+ if ((value === '' || value.startsWith('#')) && DATA_MAPPING_KEYS.has(key)) {
162
+ skipDeeperThan = keyCol;
163
+ }
164
+ }
165
+ return uses;
166
+ }
167
+ /**
168
+ * Classify one `uses:` value. Returns the parts for a remote ref that should
169
+ * be pinned but is not, or `undefined` when it is pinned, local, a container,
170
+ * or an expression.
171
+ */
172
+ export function unpinnedUse(use) {
173
+ const { ref } = use;
174
+ if (ref.startsWith('./') || ref.startsWith('.\\'))
175
+ return undefined; // local action
176
+ if (ref.startsWith('docker://'))
177
+ return undefined; // container action
178
+ if (ref.includes('${{'))
179
+ return undefined; // expression — not statically resolvable
180
+ const at = ref.lastIndexOf('@');
181
+ if (at <= 0 || !ref.slice(0, at).includes('/'))
182
+ return undefined; // not a remote action ref
183
+ const action = ref.slice(0, at);
184
+ const version = ref.slice(at + 1);
185
+ if (FULL_SHA_RE.test(version))
186
+ return undefined;
187
+ const displayable = ACTION_RE.test(action) && VERSION_RE.test(version);
188
+ return {
189
+ ...use,
190
+ displayable,
191
+ action,
192
+ version,
193
+ owner: displayable ? action.split('/')[0].toLowerCase() : '',
194
+ };
195
+ }
196
+ function shortHash(value) {
197
+ return crypto.createHash('sha1').update(value).digest('hex').slice(0, 12);
198
+ }
199
+ /** Belt and braces for text that is shown inside Markdown code spans. */
200
+ function codeSafe(text) {
201
+ return text.replace(/`/g, "'");
202
+ }
203
+ function buildIssue(context, site) {
204
+ const { filePath, lines, use } = site;
205
+ const absPath = path.resolve(context.repoRoot, filePath);
206
+ const base = {
207
+ // One finding per (file, ref): pinning the ref in one workflow must not
208
+ // re-key the finding for the other files that still use it.
209
+ id: `action-pin-${shortHash(`action-pin:${filePath}:${use.ref}`)}`,
210
+ category: 'security',
211
+ // Absolute, like every other finding's paths; the JSON report relativizes.
212
+ filePaths: [absPath],
213
+ locations: lines.map((line) => ({ filePath: absPath, startLine: line, endLine: line })),
214
+ // The ref is certainly mutable; whether that matters for this repo is the
215
+ // heuristic part, so confidence stays below 1 and the score hit modest
216
+ // (and capped — see TAG_DEDUCTION_CAP in health-score.ts).
217
+ confidence: 0.8,
218
+ tags: ['supply-chain', 'github-actions'],
219
+ };
220
+ const why = "A tag or branch is a mutable pointer: whoever can push to the action's repository can move " +
221
+ "it, and the next run executes the new code with this repository's token and secrets.";
222
+ if (!use.displayable) {
223
+ // The raw value never reaches the title, evidence, or a shell command.
224
+ return {
225
+ ...base,
226
+ severity: 'warning',
227
+ title: 'GitHub Action reference is not pinned to a commit SHA',
228
+ summary: `${filePath} has a \`uses:\` reference (line ${lines.join(', ')}) that is not pinned to a full ` +
229
+ `commit SHA and does not look like a plain \`owner/repo@ref\` value. ${why}`,
230
+ // No line numbers (baseline fingerprints hash evidence); a hash of the
231
+ // raw value keeps two different refs in one file distinct.
232
+ evidence: [`${filePath}: unrecognised uses: value (sha1 ${shortHash(use.ref)})`],
233
+ recommendation: 'Review this `uses:` line by hand. Pin the action to the full 40-character commit SHA of a ' +
234
+ "release you trust (resolved from the action's own repository), keep the version in a trailing " +
235
+ 'comment, and let Dependabot or Renovate update both. PromptCI reports this but does not rewrite ' +
236
+ 'workflow files.',
237
+ };
238
+ }
239
+ const firstParty = FIRST_PARTY_OWNERS.has(use.owner);
240
+ const [repoOwner, repoName] = use.action.split('/');
241
+ const ref = codeSafe(use.ref);
242
+ return {
243
+ ...base,
244
+ severity: firstParty ? 'info' : 'warning',
245
+ title: `GitHub Action \`${ref}\` is not pinned to a commit SHA`,
246
+ summary: `${filePath} references \`${codeSafe(use.action)}\` at \`${codeSafe(use.version)}\`, which appears ` +
247
+ `to be a tag or branch rather than a full commit SHA. ${why} ` +
248
+ (firstParty
249
+ ? 'This action is maintained by GitHub, which lowers but does not remove the risk.'
250
+ : 'Consider pinning third-party actions to a reviewed commit.'),
251
+ evidence: [`${filePath}: uses: ${ref}`],
252
+ recommendation: 'Pin the action to the full commit SHA of the release you trust and keep the version in a ' +
253
+ `trailing comment, e.g. \`uses: ${codeSafe(use.action)}@<40-char-sha> # ${codeSafe(use.version)}\`. ` +
254
+ `Resolve the SHA from the action's own repository (\`gh api repos/${repoOwner}/${repoName}/commits/` +
255
+ `${use.version} --jq .sha\`), and let Dependabot or Renovate update the SHA and comment together. ` +
256
+ 'PromptCI reports this but does not rewrite workflow files.',
257
+ };
258
+ }
259
+ export function detectActionPinning(context) {
260
+ const sources = context.workflows.sources ?? [];
261
+ // Group occurrences by (file, ref): several jobs in one workflow using the
262
+ // same unpinned action are one fix, and one finding.
263
+ const sites = new Map();
264
+ for (const source of sources) {
265
+ for (const use of extractActionUses(source.content)) {
266
+ const unpinned = unpinnedUse(use);
267
+ if (!unpinned)
268
+ continue;
269
+ const key = `${source.filePath}\u0000${unpinned.ref}`;
270
+ const site = sites.get(key) ?? { filePath: source.filePath, lines: [], use: unpinned };
271
+ site.lines.push(unpinned.line);
272
+ sites.set(key, site);
273
+ }
274
+ }
275
+ return [...sites.keys()].sort().map((key) => buildIssue(context, sites.get(key)));
276
+ }
277
+ //# sourceMappingURL=action-pinning.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"action-pinning.js","sourceRoot":"","sources":["../src/action-pinning.ts"],"names":[],"mappings":"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"}
@@ -20,5 +20,5 @@
20
20
  * All findings are info/warning severity — these are guidance gaps, not errors.
21
21
  */
22
22
  import type { InstructionFile, PromptCiIssue } from './types.js';
23
- export declare function detectAgentPractices(files: InstructionFile[]): PromptCiIssue[];
23
+ export declare function detectAgentPractices(files: InstructionFile[], aliases?: InstructionFile[]): PromptCiIssue[];
24
24
  //# sourceMappingURL=agent-practices.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"agent-practices.d.ts","sourceRoot":"","sources":["../src/agent-practices.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AA+WjE,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,eAAe,EAAE,GAAG,aAAa,EAAE,CA4T9E"}
1
+ {"version":3,"file":"agent-practices.d.ts","sourceRoot":"","sources":["../src/agent-practices.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAscjE,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,eAAe,EAAE,EAAE,OAAO,GAAE,eAAe,EAAO,GAAG,aAAa,EAAE,CAwS/G"}
@@ -22,6 +22,8 @@
22
22
  import * as crypto from 'node:crypto';
23
23
  import * as path from 'node:path';
24
24
  import { stripCodeBlocks } from './markdown-fences.js';
25
+ import { fileIdPath } from './finding-id.js';
26
+ import { aliasesWithNewType, groupAliasesByTarget } from './alias-files.js';
25
27
  const CHECKS = [
26
28
  {
27
29
  id: 'no-verification-loop',
@@ -225,6 +227,12 @@ const CLAUDE_XML_TAG_RE = /<\/?claude:[\w-]+|<instructions-for-claude[\s>]/i;
225
227
  // and must not exempt the file. The `.md` extension is required so prose like
226
228
  // "use Claude to review" cannot match the bare word.
227
229
  const FORWARDING_RE = /(?:follow|use|see|refer\s+to|canonical\s+(?:source|instructions?)|pointer\s+to)\s+[`'"*]*(?:\.?\/)?(?:\.github\/)?(?:AGENTS|CLAUDE)\.md\b/i;
230
+ // The same delegation phrased subject-first: "`AGENTS.md` is the single source
231
+ // of truth for all agents" / "AGENTS.md is canonical". Common in the tool stubs
232
+ // (GEMINI.md, .clinerules) that rule-sync setups generate next to AGENTS.md.
233
+ // Same-line only, so an unrelated "canonical" further down cannot pair up; and
234
+ // a file naming ITSELF as the source of truth is not delegating (see caller).
235
+ const SOURCE_OF_TRUTH_RE = /\b(AGENTS|CLAUDE)\.md\b[^\n]{0,60}?\b(?:single\s+source\s+of\s+truth|canonical)\b/i;
228
236
  // ── Helpers ───────────────────────────────────────────────────────────────────
229
237
  function issueId(checkId) {
230
238
  const hash = crypto.createHash('sha1').update(checkId).digest('hex').slice(0, 12);
@@ -313,6 +321,18 @@ function perFileBehaviorTarget(file) {
313
321
  if (isCopilotInstructionsFile(file)) {
314
322
  return { label: '.github/copilot-instructions.md', agent: 'GitHub Copilot' };
315
323
  }
324
+ // GEMINI.md and a single-file `.clinerules` are read standalone by their
325
+ // tool, exactly like the files above — the same BUG-19 reasoning as
326
+ // `.windsurfrules`. Files inside a `.clinerules/` DIRECTORY are not targets:
327
+ // Cline concatenates the whole directory, so one rule file lacking guidance
328
+ // that a sibling provides is not a gap. Persona files (SOUL.md etc.) are not
329
+ // tool-specific rule files, so they are not per-agent targets either.
330
+ if (file.fileType === 'gemini') {
331
+ return { label: 'GEMINI.md', agent: 'Gemini CLI' };
332
+ }
333
+ if (file.fileType === 'cline' && path.basename(file.path) === '.clinerules') {
334
+ return { label: '.clinerules', agent: 'Cline' };
335
+ }
316
336
  return undefined;
317
337
  }
318
338
  function isForwardingFile(file) {
@@ -324,13 +344,76 @@ function isForwardingFile(file) {
324
344
  const head = file.content.split(/\r?\n/).slice(0, 12).join('\n');
325
345
  if (FORWARDING_RE.test(head))
326
346
  return true;
347
+ const sourceOfTruth = SOURCE_OF_TRUTH_RE.exec(head);
348
+ if (sourceOfTruth && `${sourceOfTruth[1].toLowerCase()}.md` !== path.basename(file.path).toLowerCase()) {
349
+ return true;
350
+ }
327
351
  // Legacy: a short file with a forwarding pointer anywhere.
328
352
  return file.lineCount < 20 && FORWARDING_RE.test(file.content);
329
353
  }
330
354
  // ── Detector ─────────────────────────────────────────────────────────────────
331
- export function detectAgentPractices(files) {
355
+ /**
356
+ * Symlink aliases whose OWN tool-specific name adds a per-agent target the real
357
+ * file does not have: `CLAUDE.md -> AGENTS.md` means Claude reads AGENTS.md's
358
+ * content, so the per-file behavior checks apply to it as the CLAUDE.md it is.
359
+ * An alias whose target is the same kind of file as the real one adds nothing.
360
+ */
361
+ function aliasesWithNewTarget(files, aliases) {
362
+ return aliases.filter((alias) => {
363
+ const target = perFileBehaviorTarget(alias);
364
+ if (!target)
365
+ return false;
366
+ const canonical = files.find((f) => f.relativePath === alias.aliasOf);
367
+ return canonical === undefined || perFileBehaviorTarget(canonical)?.label !== target.label;
368
+ });
369
+ }
370
+ /** A Claude XML tag is only meaningful in a CLAUDE.md; README files document the tags as examples. */
371
+ function isClaudeTagTarget(fileType) {
372
+ return fileType !== 'claude' && fileType !== 'readme';
373
+ }
374
+ /**
375
+ * BUG-007: Claude-specific XML tags in a non-Claude instruction file.
376
+ * <claude:thinking> and similar tags are Claude-specific features; embedding them
377
+ * in .cursorrules or AGENTS.md is tool-specific syntax in the wrong file type.
378
+ * Skip README files — they often document Claude XML tags as examples rather than using them
379
+ * Skip if the tag only appears inside fenced code blocks (documentation, not real usage)
380
+ */
381
+ function claudeXmlTagIssue(names) {
382
+ // `names` is one real file, or every link to one real file (one finding listing them all, id from the first).
383
+ const file = names[0];
384
+ if (!isClaudeTagTarget(file.fileType))
385
+ return undefined;
386
+ const contentOutsideCode = stripCodeBlocks(file.content).replace(/`[^`\n]+`/g, '');
387
+ if (!CLAUDE_XML_TAG_RE.test(contentOutsideCode))
388
+ return undefined;
389
+ const hash = crypto.createHash('sha1').update(`claude-xml-tag:${fileIdPath(file)}`).digest('hex').slice(0, 12);
390
+ // AP3: search contentOutsideCode (the same text the gate above tested),
391
+ // not the raw file.content — otherwise an earlier occurrence of the
392
+ // tag that exists ONLY inside a code fence (documentation, not real
393
+ // usage) could be picked as evidence instead of the real match that
394
+ // actually made the gate fire.
395
+ const firstTag = (/<\/?claude:[\w-]+/i).exec(contentOutsideCode)?.[0] ?? '<claude:...>';
396
+ return {
397
+ id: `claude-xml-tag-${hash}`,
398
+ severity: 'warning',
399
+ category: 'structure',
400
+ title: 'Claude-specific XML tag in non-Claude instruction file',
401
+ summary: `This file contains a Claude-specific XML tag (${firstTag}) but is not a CLAUDE.md file. ` +
402
+ `Claude XML tags are only interpreted by Claude; other AI tools (Cursor, Copilot, etc.) ` +
403
+ `will treat them as literal text or ignore them.`,
404
+ filePaths: names.map((f) => f.path),
405
+ locations: names.map((f) => ({ filePath: f.path })),
406
+ evidence: [`Found: ${firstTag} in ${[...new Set(names.map((f) => f.fileType))].join(', ')} file`],
407
+ recommendation: 'Move Claude-specific directives to CLAUDE.md, or replace this tag with a plain-text ' +
408
+ 'instruction that all tools can understand.',
409
+ confidence: 0.85,
410
+ };
411
+ }
412
+ export function detectAgentPractices(files, aliases = []) {
332
413
  if (files.length === 0)
333
414
  return [];
415
+ // Files the per-agent checks run over: every scanned file plus the symlink aliases that are a different tool's file.
416
+ const targetFiles = [...files, ...aliasesWithNewTarget(files, aliases)];
334
417
  const issues = [];
335
418
  const combinedContent = files.map((f) => f.content).join('\n');
336
419
  // BUG-002: For the verification-loop check, strip human-facing checklist items
@@ -378,7 +461,7 @@ export function detectAgentPractices(files) {
378
461
  // granular "missing critical guidance" issues (one per CHECKS entry),
379
462
  // inflating the health-score penalty ~6x for a single underlying gap.
380
463
  const filesWithNoGuidanceFinding = new Set();
381
- for (const file of files) {
464
+ for (const file of targetFiles) {
382
465
  const target = perFileBehaviorTarget(file);
383
466
  if (!target)
384
467
  continue;
@@ -396,7 +479,7 @@ export function detectAgentPractices(files) {
396
479
  continue;
397
480
  filesWithNoGuidanceFinding.add(file.path);
398
481
  issues.push({
399
- id: perFileBehaviorIssueId(file.path),
482
+ id: perFileBehaviorIssueId(fileIdPath(file)),
400
483
  severity: 'warning',
401
484
  category: 'agent_practices',
402
485
  title: `No behavioral guidance in ${target.label}`,
@@ -424,7 +507,7 @@ export function detectAgentPractices(files) {
424
507
  const sourceFile = files.find((f) => matchesAny(stripChecklistLines(f.content), check.patterns));
425
508
  if (!sourceFile)
426
509
  continue;
427
- for (const file of files) {
510
+ for (const file of targetFiles) {
428
511
  if (file === sourceFile)
429
512
  continue;
430
513
  const target = perFileBehaviorTarget(file);
@@ -439,7 +522,7 @@ export function detectAgentPractices(files) {
439
522
  const hasThisGuidance = matchesAny(stripChecklistLines(file.content), check.patterns);
440
523
  if (!hasThisGuidance) {
441
524
  const checkName = check.title.toLowerCase().replace('no ', '').replace(' instruction', '');
442
- const hash = crypto.createHash('sha1').update(`behavior-gap:${file.path}:${check.id}`).digest('hex').slice(0, 12);
525
+ const hash = crypto.createHash('sha1').update(`behavior-gap:${fileIdPath(file)}:${check.id}`).digest('hex').slice(0, 12);
443
526
  issues.push({
444
527
  id: `per-file-behavioral-gap-${hash}`,
445
528
  severity: 'warning',
@@ -488,7 +571,7 @@ export function detectAgentPractices(files) {
488
571
  if (hasGeneralBehaviorOnlyInCopilot()) {
489
572
  const copilotFile = files.find(isCopilotInstructionsFile);
490
573
  issues.push({
491
- id: copilotAgentBehaviorId(copilotFile.path),
574
+ id: copilotAgentBehaviorId(fileIdPath(copilotFile)),
492
575
  severity: 'warning',
493
576
  category: 'structure',
494
577
  title: 'General behavioral guidance placed only in Copilot instructions',
@@ -511,10 +594,10 @@ export function detectAgentPractices(files) {
511
594
  if (!matchesAny(file.content, KEY_BEHAVIORAL_PATTERNS))
512
595
  continue;
513
596
  // Check if we already flagged this via general-behavior-only check
514
- if (issues.some(i => i.id === copilotAgentBehaviorId(file.path)))
597
+ if (issues.some(i => i.id === copilotAgentBehaviorId(fileIdPath(file))))
515
598
  continue;
516
599
  issues.push({
517
- id: copilotAgentBehaviorId(file.path),
600
+ id: copilotAgentBehaviorId(fileIdPath(file)),
518
601
  severity: 'warning',
519
602
  category: 'structure',
520
603
  title: 'Agent-behavior section in copilot-instructions.md',
@@ -545,7 +628,7 @@ export function detectAgentPractices(files) {
545
628
  if (hasDoNotEdit && !hasGeneratorNote) {
546
629
  // Case 1 (original): "do not edit" present but NO generator note.
547
630
  issues.push({
548
- id: doNotEditIssueId(file.path),
631
+ id: doNotEditIssueId(fileIdPath(file)),
549
632
  severity: 'warning',
550
633
  category: 'agent_practices',
551
634
  title: '"Do not edit" header in human-maintained instruction file',
@@ -568,7 +651,7 @@ export function detectAgentPractices(files) {
568
651
  return `auto-gen-header-${hash}`;
569
652
  };
570
653
  issues.push({
571
- id: autoGenId(file.path),
654
+ id: autoGenId(fileIdPath(file)),
572
655
  severity: 'warning',
573
656
  category: 'agent_practices',
574
657
  title: 'Misleading auto-generated header on human-maintained instruction file',
@@ -583,39 +666,20 @@ export function detectAgentPractices(files) {
583
666
  confidence: 0.75,
584
667
  });
585
668
  }
586
- // BUG-007: Detect Claude-specific XML tags in non-Claude instruction files.
587
- // <claude:thinking> and similar tags are Claude-specific features; embedding them
588
- // in .cursorrules or AGENTS.md is tool-specific syntax in the wrong file type.
589
- // Skip README files — they often document Claude XML tags as examples rather than using them
590
- // Skip if the tag only appears inside fenced code blocks (documentation, not real usage)
591
- const contentOutsideCode = stripCodeBlocks(file.content).replace(/`[^`\n]+`/g, '');
592
- if (file.fileType !== 'claude' && file.fileType !== 'readme' && CLAUDE_XML_TAG_RE.test(contentOutsideCode)) {
593
- const claudeTagId = (p) => {
594
- const hash = crypto.createHash('sha1').update(`claude-xml-tag:${p}`).digest('hex').slice(0, 12);
595
- return `claude-xml-tag-${hash}`;
596
- };
597
- // AP3: search contentOutsideCode (the same text the gate above tested),
598
- // not the raw file.content — otherwise an earlier occurrence of the
599
- // tag that exists ONLY inside a code fence (documentation, not real
600
- // usage) could be picked as evidence instead of the real match that
601
- // actually made the gate fire.
602
- const firstTag = (/<\/?claude:[\w-]+/i).exec(contentOutsideCode)?.[0] ?? '<claude:...>';
603
- issues.push({
604
- id: claudeTagId(file.path),
605
- severity: 'warning',
606
- category: 'structure',
607
- title: 'Claude-specific XML tag in non-Claude instruction file',
608
- summary: `This file contains a Claude-specific XML tag (${firstTag}) but is not a CLAUDE.md file. ` +
609
- `Claude XML tags are only interpreted by Claude; other AI tools (Cursor, Copilot, etc.) ` +
610
- `will treat them as literal text or ignore them.`,
611
- filePaths: [file.path],
612
- locations: [{ filePath: file.path }],
613
- evidence: [`Found: ${firstTag} in ${file.fileType} file`],
614
- recommendation: 'Move Claude-specific directives to CLAUDE.md, or replace this tag with a plain-text ' +
615
- 'instruction that all tools can understand.',
616
- confidence: 0.85,
617
- });
618
- }
669
+ const claudeTag = claudeXmlTagIssue([file]);
670
+ if (claudeTag)
671
+ issues.push(claudeTag);
672
+ }
673
+ // The Claude-tag check is gated by file type, and `AGENTS.md -> CLAUDE.md` is an AGENTS.md to every
674
+ // tool that reads that name: run it for such an alias, against the real content. (An alias of a file
675
+ // that is already "not Claude" would only repeat the real file's finding, so it is left out.) The
676
+ // other per-type content checks above are not repeated for aliases: "do not edit" is type-independent,
677
+ // and the Copilot agent-behavior check assumes copilot-instructions.md is Copilot-only, which a link
678
+ // to AGENTS.md is not. Several links to one file give ONE finding that lists every link.
679
+ for (const group of groupAliasesByTarget(aliasesWithNewType(files, aliases, isClaudeTagTarget))) {
680
+ const claudeTag = claudeXmlTagIssue(group);
681
+ if (claudeTag)
682
+ issues.push(claudeTag);
619
683
  }
620
684
  return issues;
621
685
  }